From 6b9be800b5c3a2b14126e8669cdce173bde94a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Tue, 22 Sep 2026 22:39:35 +0200 Subject: [PATCH 01/37] rust: share reflected tool schema pools --- .../rust/golem-rust/src/agentic/reflection.rs | 5 +++ .../golem-rust/src/agentic/tool_reflection.rs | 35 ++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/sdks/rust/golem-rust/src/agentic/reflection.rs b/sdks/rust/golem-rust/src/agentic/reflection.rs index ad4eba2cec..2613b42d6e 100644 --- a/sdks/rust/golem-rust/src/agentic/reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/reflection.rs @@ -59,6 +59,11 @@ impl SchemaRef { Self { graph, root } } + #[cfg(test)] + pub(crate) fn shares_definition_pool_with(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.graph, &other.graph) + } + pub fn graph(&self) -> &SchemaGraph { &self.graph } diff --git a/sdks/rust/golem-rust/src/agentic/tool_reflection.rs b/sdks/rust/golem-rust/src/agentic/tool_reflection.rs index 4fc87f2e99..dc722a66d4 100644 --- a/sdks/rust/golem-rust/src/agentic/tool_reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/tool_reflection.rs @@ -224,7 +224,6 @@ impl ToolType { .body .as_ref() .expect("resolved body"); - let input_graph = Arc::new(input.record_schema.clone()); let mut arguments = Vec::with_capacity(input.fields.len()); let surfaces = self.definition.canonical_input_surfaces(index); input @@ -272,7 +271,7 @@ impl ToolType { name: field.name.clone(), aliases: field.aliases.clone(), short: field.short, - schema: SchemaRef::with_root(input_graph.clone(), field.type_.clone()), + schema: SchemaRef::with_root(self.schema.clone(), field.type_.clone()), required, default, }); @@ -297,7 +296,7 @@ impl ToolType { tool: self.clone(), index, path: canonical_path, - input: SchemaRef::with_root(input_graph, input.record_schema.root.clone()), + input: SchemaRef::with_root(self.schema.clone(), input.record_schema.root.clone()), wire_input: input.record_schema, arguments, }) @@ -1321,7 +1320,7 @@ mod tests { env_var: None, }); let command = tool.command(&["run"]).unwrap(); - assert_eq!(command.input_schema().graph(), &command.wire_input); + assert_eq!(command.input_schema().root(), &command.wire_input.root); for fields in [ vec![ SchemaValue::Option { inner: None }, @@ -1341,6 +1340,34 @@ mod tests { } } + #[test] + fn repeated_command_lookup_shares_the_discovered_definition_pool() { + let tool = sample(); + let first = tool.command(&["run"]).unwrap(); + let second = tool.command(&["r"]).unwrap(); + + assert!( + first + .input_schema() + .shares_definition_pool_with(second.input_schema()) + ); + assert!( + first + .input_schema() + .shares_definition_pool_with(&first.arguments()[0].schema) + ); + assert!( + first + .input_schema() + .shares_definition_pool_with(&first.output_schema().unwrap()) + ); + assert!( + !first + .input_schema() + .shares_definition_pool_with(&SchemaRef::new(first.wire_input.clone())) + ); + } + #[test] fn malformed_remote_output_is_distinct() { let command = sample().command(&["run"]).expect("command"); From 3fc74426eb0d6ae3a81f906d5611fe83a22f42ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Tue, 22 Sep 2026 23:31:31 +0200 Subject: [PATCH 02/37] moonbit: make reflected snapshots immutable --- .../golem-agent-reflection-moonbit.mdx | 15 +- .../golem-agent-reflection-moonbit/SKILL.md | 15 +- sdks/moonbit/golem_sdk/README.mbt.md | 6 +- .../golem_sdk/reflection/config_wbtest.mbt | 14 ++ .../golem_sdk/reflection/pkg.generated.mbti | 54 +++++-- .../golem_sdk/reflection/reflected_client.mbt | 92 ++++++++++- .../golem_sdk/reflection/schema_ref.mbt | 142 +++++++++++++++- .../golem_sdk/reflection/tool_reflection.mbt | 151 +++++++++++++++++- .../reflection/tool_reflection_wbtest.mbt | 38 +++++ .../tool_reflection.mbt | 30 ++-- 10 files changed, 512 insertions(+), 45 deletions(-) diff --git a/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx b/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx index 91d358a29b..2e5a037261 100644 --- a/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx +++ b/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx @@ -49,7 +49,7 @@ let result = counter.invoke_json( ) ``` -For explicit reflected packing, call `add.input.pack_json`, invoke through +For explicit reflected packing, call `add.input().pack_json`, invoke through `invoke_value`, await the result, and call the output `SchemaRef::unpack_json`. Reflected schema-native constructor and method inputs are checked locally against the selected schema graph before opening RPC. Declared native outputs @@ -116,14 +116,17 @@ async fn invoke_discovered_tool_dynamically() -> String { Ok(value) => value Err(error) => return "input:\{describe_dynamic_tool_error(error)}" } - command.input_schema.validate_value(packed) catch { + command.input_schema().validate_value(packed) catch { + error => return "input:\{Repr(error)}" + } + let typed_input = command.input_schema().typed_value(packed) catch { error => return "input:\{Repr(error)}" } - let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name) + let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name()) let raw = match dynamic.invoke_value( - command.path, - { graph: command.input_schema.graph, value: packed }, + command.path(), + typed_input, ) { Ok(raw) => raw Err(error) => { @@ -131,7 +134,7 @@ async fn invoke_discovered_tool_dynamically() -> String { } } - match (command.result, raw.result) { + match (command.result(), raw.result) { (None, None) => "ok" (Some(schema), Some(output)) => { schema.validate_value(output.value) catch { diff --git a/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md b/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md index aba3677731..7aad9fc89a 100644 --- a/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md +++ b/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md @@ -54,7 +54,7 @@ let result = counter.invoke_json( ) ``` -For explicit reflected packing, call `add.input.pack_json`, invoke through +For explicit reflected packing, call `add.input().pack_json`, invoke through `invoke_value`, await the result, and call the output `SchemaRef::unpack_json`. Reflected schema-native constructor and method inputs are checked locally against the selected schema graph before opening RPC. Declared native outputs @@ -121,14 +121,17 @@ async fn invoke_discovered_tool_dynamically() -> String { Ok(value) => value Err(error) => return "input:\{describe_dynamic_tool_error(error)}" } - command.input_schema.validate_value(packed) catch { + command.input_schema().validate_value(packed) catch { + error => return "input:\{Repr(error)}" + } + let typed_input = command.input_schema().typed_value(packed) catch { error => return "input:\{Repr(error)}" } - let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name) + let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name()) let raw = match dynamic.invoke_value( - command.path, - { graph: command.input_schema.graph, value: packed }, + command.path(), + typed_input, ) { Ok(raw) => raw Err(error) => { @@ -136,7 +139,7 @@ async fn invoke_discovered_tool_dynamically() -> String { } } - match (command.result, raw.result) { + match (command.result(), raw.result) { (None, None) => "ok" (Some(schema), Some(output)) => { schema.validate_value(output.value) catch { diff --git a/sdks/moonbit/golem_sdk/README.mbt.md b/sdks/moonbit/golem_sdk/README.mbt.md index 372dabbb35..e62e3d9f85 100644 --- a/sdks/moonbit/golem_sdk/README.mbt.md +++ b/sdks/moonbit/golem_sdk/README.mbt.md @@ -231,10 +231,14 @@ canonical `grep`/`git` examples for the complete annotation surface. `@reflection.get_tool_type(name)` returns the tool descriptor visible to the caller. Select a command with `tool.command(path)`; path segments may use command aliases, and the returned -`ToolCommand.path` contains their canonical names. Its `arguments`, `input_schema`, and `result` +`ToolCommand.path()` contains their canonical names. Its `arguments()`, `input_schema()`, and `result()` describe the selected command. Supply every canonical input field in declaration order. An absent optional argument is an option value of `None`, while a defaulted argument carries its default. +Discovery returns opaque immutable snapshots. Array and graph accessors return defensive copies, +and a snapshot changes only after explicit rediscovery. Use `SchemaRef::typed_value` to validate a +schema-native value and create the self-contained graph required by a fully dynamic invocation. + Use `command.pack_json(input)` and `command.invoke_json(input)` for canonical JSON, or `command.invoke_value(input)` for schema-native values. The command validates inputs locally before opening RPC and validates declared results after invocation. `start_value` returns an invocation diff --git a/sdks/moonbit/golem_sdk/reflection/config_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/config_wbtest.mbt index b22ec0b7fc..8f6e296dfc 100644 --- a/sdks/moonbit/golem_sdk/reflection/config_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/config_wbtest.mbt @@ -91,3 +91,17 @@ test "native config accepts large integers without a JSON round trip" { _ => fail("expected JSON unsafe integer to be rejected") } } + +///| +test "agent snapshot accessors return defensive nested copies" { + let agent_type = configured_reflected_type() + let declarations = agent_type.config() + let path = declarations[0].path() + path[0] = "mutated" + declarations[0] = declarations[1] + let graph = agent_type.constructor_input().graph() + graph.root.metadata.aliases.push("mutated") + + assert_eq(agent_type.config()[0].path(), ["greeting"]) + assert_eq(agent_type.constructor_input().root().metadata.aliases, []) +} diff --git a/sdks/moonbit/golem_sdk/reflection/pkg.generated.mbti b/sdks/moonbit/golem_sdk/reflection/pkg.generated.mbti index c9c3c4f8c3..859ebd4698 100644 --- a/sdks/moonbit/golem_sdk/reflection/pkg.generated.mbti +++ b/sdks/moonbit/golem_sdk/reflection/pkg.generated.mbti @@ -34,13 +34,18 @@ pub(all) suberror ReflectError { } derive(Eq, @debug.Debug) // Types and methods -pub(all) struct AgentMethod { +pub struct AgentMethod { name : String description : String prompt_hint : String? input : SchemaRef output : SchemaRef? } derive(Eq, @debug.Debug) +pub fn AgentMethod::description(Self) -> String +pub fn AgentMethod::input(Self) -> SchemaRef +pub fn AgentMethod::name(Self) -> String +pub fn AgentMethod::output(Self) -> SchemaRef? +pub fn AgentMethod::prompt_hint(Self) -> String? pub(all) enum AgentMode { Durable @@ -59,7 +64,7 @@ pub(all) enum AgentRpcError { Custom(@schema_model.TypedSchemaValue) } derive(Eq, @debug.Debug) -pub(all) struct AgentType { +pub struct AgentType { name : String description : String source_language : String @@ -71,6 +76,9 @@ pub(all) struct AgentType { } derive(Eq, @debug.Debug) pub fn AgentType::agent_id_json(Self, Json, phantom_id? : @types.Uuid?) -> ParsedAgentId raise ReflectError pub fn AgentType::agent_id_value(Self, @schema_model.SchemaValue, phantom_id? : @types.Uuid?) -> ParsedAgentId raise ReflectError +pub fn AgentType::config(Self) -> Array[ReflectedConfigDeclaration] +pub fn AgentType::constructor_input(Self) -> SchemaRef +pub fn AgentType::description(Self) -> String pub fn AgentType::ephemeral_json(Self, Json) -> ReflectedAgentClient raise ReflectError pub fn AgentType::ephemeral_json_with_config(Self, Json, Array[ReflectedConfigJsonValue]) -> ReflectedAgentClient raise ReflectError pub fn AgentType::ephemeral_phantom_json(Self, Json, @types.Uuid) -> ReflectedAgentClient raise ReflectError @@ -86,6 +94,10 @@ pub fn AgentType::get_phantom_json_with_config(Self, Json, @types.Uuid, Array[Re pub fn AgentType::get_phantom_value_with_config(Self, @schema_model.SchemaValue, @types.Uuid, Array[ReflectedConfigValue]) -> ReflectedAgentClient raise ReflectError pub fn AgentType::get_value(Self, @schema_model.SchemaValue) -> ReflectedAgentClient raise ReflectError pub fn AgentType::get_value_with_config(Self, @schema_model.SchemaValue, Array[ReflectedConfigValue]) -> ReflectedAgentClient raise ReflectError +pub fn AgentType::implemented_by(Self) -> @types.ComponentId +pub fn AgentType::methods(Self) -> Array[AgentMethod] +pub fn AgentType::mode(Self) -> AgentMode +pub fn AgentType::name(Self) -> String pub fn AgentType::new_phantom_json(Self, Json) -> ReflectedAgentClient raise ReflectError pub fn AgentType::new_phantom_json_with_config(Self, Json, Array[ReflectedConfigJsonValue]) -> ReflectedAgentClient raise ReflectError pub fn AgentType::new_phantom_value(Self, @schema_model.SchemaValue) -> ReflectedAgentClient raise ReflectError @@ -93,6 +105,7 @@ pub fn AgentType::new_phantom_value_with_config(Self, @schema_model.SchemaValue, pub fn AgentType::pack_config_json(Self, Array[ReflectedConfigJsonValue]) -> Array[@golemcloud/golem_sdk/interface/golem/agent/common.TypedAgentConfigValue] raise ReflectError pub fn AgentType::resume_phantom_json(Self, Json, @types.Uuid) -> ReflectedAgentClient raise ReflectError pub fn AgentType::resume_phantom_value(Self, @schema_model.SchemaValue, @types.Uuid) -> ReflectedAgentClient raise ReflectError +pub fn AgentType::source_language(Self) -> String pub fn AgentType::validate_config_values(Self, Array[ReflectedConfigValue]) -> Array[@golemcloud/golem_sdk/interface/golem/agent/common.TypedAgentConfigValue] raise ReflectError pub(all) struct CreatedPhantomClient { @@ -192,11 +205,14 @@ pub fn ReflectedAgentClient::schedule_value(Self, @system-clock.Instant, String, pub fn ReflectedAgentClient::trigger_json(Self, String, Json) -> InvocationMetadata raise ReflectError pub fn ReflectedAgentClient::trigger_value(Self, String, @schema_model.SchemaValue) -> InvocationMetadata raise ReflectError -pub(all) struct ReflectedConfigDeclaration { +pub struct ReflectedConfigDeclaration { path : Array[String] source : @golemcloud/golem_sdk/interface/golem/agent/common.AgentConfigSource schema : SchemaRef } derive(Eq, @debug.Debug) +pub fn ReflectedConfigDeclaration::path(Self) -> Array[String] +pub fn ReflectedConfigDeclaration::schema(Self) -> SchemaRef +pub fn ReflectedConfigDeclaration::source(Self) -> @golemcloud/golem_sdk/interface/golem/agent/common.AgentConfigSource pub(all) struct ReflectedConfigJsonValue { path : Array[String] @@ -208,10 +224,11 @@ pub(all) struct ReflectedConfigValue { value : @schema_model.SchemaValue } derive(Eq, @debug.Debug) -pub(all) struct ReflectedToolClient { +pub struct ReflectedToolClient { tool : ToolType } derive(Eq, @debug.Debug) pub fn ReflectedToolClient::command(Self, Array[String]) -> ToolCommand raise ReflectError +pub fn ReflectedToolClient::tool(Self) -> ToolType pub(all) struct ReflectedToolCustomError { name : String @@ -234,18 +251,20 @@ pub(all) struct ScheduledInvocation { pub fn ScheduledInvocation::cancel(Self) -> Unit pub fn ScheduledInvocation::drop(Self) -> Unit -pub(all) struct SchemaRef { +pub struct SchemaRef { graph : @schema_model.SchemaGraph root : @schema_model.SchemaType } derive(Eq, @debug.Debug) pub fn SchemaRef::contains_stream(Self) -> Bool -pub fn SchemaRef::new(@schema_model.SchemaGraph) -> Self +pub fn SchemaRef::graph(Self) -> @schema_model.SchemaGraph pub fn SchemaRef::pack_json(Self, Json) -> @schema_model.SchemaValue raise ReflectError +pub fn SchemaRef::root(Self) -> @schema_model.SchemaType pub fn SchemaRef::to_json_schema(Self, include_draft_marker? : Bool) -> Json +pub fn SchemaRef::typed_value(Self, @schema_model.SchemaValue) -> @schema_model.TypedSchemaValue raise ReflectError pub fn SchemaRef::unpack_json(Self, @schema_model.SchemaValue) -> Json raise ReflectError pub fn SchemaRef::validate_value(Self, @schema_model.SchemaValue) -> Unit raise ReflectError -pub(all) struct ToolArgument { +pub struct ToolArgument { kind : String name : String aliases : Array[String] @@ -255,6 +274,13 @@ pub(all) struct ToolArgument { schema : SchemaRef optional_carrier : Bool } derive(Eq, @debug.Debug) +pub fn ToolArgument::aliases(Self) -> Array[String] +pub fn ToolArgument::default(Self) -> @schema_model.SchemaValue? raise ReflectError +pub fn ToolArgument::kind(Self) -> String +pub fn ToolArgument::name(Self) -> String +pub fn ToolArgument::required(Self) -> Bool +pub fn ToolArgument::schema(Self) -> SchemaRef +pub fn ToolArgument::short(Self) -> Char? pub(all) struct ToolClientDefinition[Client] { name : String? @@ -265,7 +291,7 @@ pub fn[Client] ToolClientDefinition::client_for(Self[Client], String) -> Client pub fn[Client] ToolClientDefinition::named(String, (String) -> Client) -> Self[Client] pub fn[Client] ToolClientDefinition::unnamed((String) -> Client) -> Self[Client] -pub(all) struct ToolCommand { +pub struct ToolCommand { tool : ToolType path : Array[String] body : @golemcloud/golem_sdk/interface/golem/tool/common.CommandBody? @@ -275,14 +301,21 @@ pub(all) struct ToolCommand { result : SchemaRef? // private fields } derive(Eq, @debug.Debug) +pub fn ToolCommand::arguments(Self) -> Array[ToolArgument] +pub fn ToolCommand::input_schema(Self) -> SchemaRef pub async fn ToolCommand::invoke_json(Self, Json, stdin? : @async-core.Stream[Byte]?) -> Result[Json?, @tool.ToolError[ReflectedToolCustomError]] pub async fn ToolCommand::invoke_value(Self, @schema_model.SchemaValue, stdin? : @async-core.Stream[Byte]?) -> Result[@schema_model.SchemaValue?, @tool.ToolError[ReflectedToolCustomError]] +pub fn ToolCommand::is_callable(Self) -> Bool pub fn ToolCommand::pack_json(Self, Json) -> Result[@schema_model.SchemaValue, @tool.ToolError[ReflectedToolCustomError]] +pub fn ToolCommand::path(Self) -> Array[String] +pub fn ToolCommand::result(Self) -> SchemaRef? pub fn ToolCommand::start_value(Self, @schema_model.SchemaValue, stdin? : @async-core.Stream[Byte]?) -> Result[ReflectedToolInvocation, @tool.ToolError[ReflectedToolCustomError]] +pub fn ToolCommand::subcommands(Self) -> Array[String] +pub fn ToolCommand::tool(Self) -> ToolType pub fn ToolCommand::trigger_json(Self, Json, stdin? : @async-core.Stream[Byte]?) -> Result[Unit, @tool.ToolError[ReflectedToolCustomError]] pub fn ToolCommand::trigger_value(Self, @schema_model.SchemaValue, stdin? : @async-core.Stream[Byte]?) -> Result[Unit, @tool.ToolError[ReflectedToolCustomError]] -pub(all) struct ToolType { +pub struct ToolType { lookup_name : String definition : @golemcloud/golem_sdk/interface/golem/tool/common.Tool implemented_by : @types.ComponentId @@ -291,6 +324,9 @@ pub(all) struct ToolType { pub fn ToolType::client(Self) -> ReflectedToolClient pub fn ToolType::command(Self, Array[String]) -> ToolCommand raise ReflectError pub fn ToolType::from_registered(@golemcloud/golem_sdk/interface/golem/tool/host.RegisteredTool) -> Self raise ReflectError +pub fn ToolType::implemented_by(Self) -> @types.ComponentId +pub fn ToolType::lookup_name(Self) -> String +pub fn ToolType::version(Self) -> String // Type aliases diff --git a/sdks/moonbit/golem_sdk/reflection/reflected_client.mbt b/sdks/moonbit/golem_sdk/reflection/reflected_client.mbt index 3458c00cb1..e6eb36ef05 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflected_client.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflected_client.mbt @@ -1,6 +1,6 @@ ///| /// A method discovered from the runtime-owned agent schema. -pub(all) struct AgentMethod { +pub struct AgentMethod { name : String description : String prompt_hint : String? @@ -9,7 +9,7 @@ pub(all) struct AgentMethod { } derive(Debug, Eq) ///| -pub(all) struct ReflectedConfigDeclaration { +pub struct ReflectedConfigDeclaration { path : Array[String] source : @common.AgentConfigSource schema : SchemaRef @@ -29,7 +29,7 @@ pub(all) struct ReflectedConfigValue { ///| /// An agent type discovered from the runtime registry. -pub(all) struct AgentType { +pub struct AgentType { name : String description : String source_language : String @@ -40,6 +40,92 @@ pub(all) struct AgentType { config : Array[ReflectedConfigDeclaration] } derive(Debug, Eq) +///| +pub fn AgentMethod::name(self : AgentMethod) -> String { + self.name +} + +///| +pub fn AgentMethod::description(self : AgentMethod) -> String { + self.description +} + +///| +pub fn AgentMethod::prompt_hint(self : AgentMethod) -> String? { + self.prompt_hint +} + +///| +pub fn AgentMethod::input(self : AgentMethod) -> SchemaRef { + self.input +} + +///| +pub fn AgentMethod::output(self : AgentMethod) -> SchemaRef? { + self.output +} + +///| +pub fn ReflectedConfigDeclaration::path( + self : ReflectedConfigDeclaration, +) -> Array[String] { + self.path.copy() +} + +///| +pub fn ReflectedConfigDeclaration::source( + self : ReflectedConfigDeclaration, +) -> @common.AgentConfigSource { + self.source +} + +///| +pub fn ReflectedConfigDeclaration::schema( + self : ReflectedConfigDeclaration, +) -> SchemaRef { + self.schema +} + +///| +pub fn AgentType::name(self : AgentType) -> String { + self.name +} + +///| +pub fn AgentType::description(self : AgentType) -> String { + self.description +} + +///| +pub fn AgentType::source_language(self : AgentType) -> String { + self.source_language +} + +///| +pub fn AgentType::mode(self : AgentType) -> AgentMode { + self.mode +} + +///| +pub fn AgentType::implemented_by(self : AgentType) -> @types.ComponentId { + self.implemented_by +} + +///| +pub fn AgentType::constructor_input(self : AgentType) -> SchemaRef { + self.constructor_input +} + +///| +pub fn AgentType::methods(self : AgentType) -> Array[AgentMethod] { + self.methods.copy() +} + +///| +pub fn AgentType::config(self : AgentType) -> Array[ReflectedConfigDeclaration] { + self.config.copy() +} + ///| /// A reflected client and any reusable identity created before invocation. /// Ephemeral clients have no reusable identity until invocation metadata is diff --git a/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt b/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt index 46b8a4239f..01cf2f203b 100644 --- a/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt +++ b/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt @@ -1,15 +1,153 @@ ///| /// A graph-backed view of one reflected schema root. -pub(all) struct SchemaRef { +pub struct SchemaRef { graph : @model.SchemaGraph root : @model.SchemaType } derive(Debug, Eq) ///| -pub fn SchemaRef::new(graph : @model.SchemaGraph) -> SchemaRef { +fn SchemaRef::new(graph : @model.SchemaGraph) -> SchemaRef { { root: graph.root, graph, } } +///| +fn clone_metadata( + metadata : @types.MetadataEnvelope, +) -> @types.MetadataEnvelope { + { + ..metadata, + aliases: metadata.aliases.copy(), + examples: metadata.examples.copy(), + } +} + +///| +fn clone_schema_type(schema : @model.SchemaType) -> @model.SchemaType { + let body = match schema.body { + Record(fields) => + @model.SchemaTypeBody::Record( + fields.map(field => { + ..field, + body: clone_schema_type(field.body), + metadata: clone_metadata(field.metadata), + }), + ) + Variant(cases) => + @model.SchemaTypeBody::Variant( + cases.map(case => { + ..case, + payload: case.payload.map(clone_schema_type), + metadata: clone_metadata(case.metadata), + }), + ) + Enum(cases) => @model.SchemaTypeBody::Enum(cases.copy()) + Flags(names) => @model.SchemaTypeBody::Flags(names.copy()) + Tuple(elements) => + @model.SchemaTypeBody::Tuple(elements.map(clone_schema_type)) + List(element) => @model.SchemaTypeBody::List(clone_schema_type(element)) + FixedList(element, length) => + @model.SchemaTypeBody::FixedList(clone_schema_type(element), length) + Map(key, value) => + @model.SchemaTypeBody::Map( + clone_schema_type(key), + clone_schema_type(value), + ) + Option(element) => @model.SchemaTypeBody::Option(clone_schema_type(element)) + Result(ok, err) => + @model.SchemaTypeBody::Result( + ok.map(clone_schema_type), + err.map(clone_schema_type), + ) + Text(restrictions) => + @model.SchemaTypeBody::Text({ + ..restrictions, + languages: restrictions.languages.map(values => values.copy()), + }) + Binary(restrictions) => + @model.SchemaTypeBody::Binary({ + ..restrictions, + mime_types: restrictions.mime_types.map(values => values.copy()), + }) + Path(spec) => + @model.SchemaTypeBody::Path({ + ..spec, + allowed_mime_types: spec.allowed_mime_types.map(values => values.copy()), + allowed_extensions: spec.allowed_extensions.map(values => values.copy()), + }) + Url(restrictions) => + @model.SchemaTypeBody::Url({ + allowed_schemes: restrictions.allowed_schemes.map(values => { + values.copy() + }), + allowed_hosts: restrictions.allowed_hosts.map(values => values.copy()), + }) + Quantity(spec) => + @model.SchemaTypeBody::Quantity({ + ..spec, + allowed_suffixes: spec.allowed_suffixes.copy(), + }) + Union(branches) => + @model.SchemaTypeBody::Union( + branches.map(branch => { + ..branch, + body: clone_schema_type(branch.body), + metadata: clone_metadata(branch.metadata), + }), + ) + Secret(spec) => + @model.SchemaTypeBody::Secret({ + ..spec, + inner: clone_schema_type(spec.inner), + }) + Future(element) => + @model.SchemaTypeBody::Future(element.map(clone_schema_type)) + Stream(element) => + @model.SchemaTypeBody::Stream(element.map(clone_schema_type)) + other => other + } + { body, metadata: clone_metadata(schema.metadata), } +} + +///| +fn clone_schema_graph(graph : @model.SchemaGraph) -> @model.SchemaGraph { + { + defs: graph.defs.map(def => { ..def, body: clone_schema_type(def.body), }), + root: clone_schema_type(graph.root), + } +} + +///| +fn SchemaRef::owned_graph(self : SchemaRef) -> @model.SchemaGraph { + let closed = @model.schema_graph_closure({ + defs: self.graph.defs, + root: self.root, + }) + clone_schema_graph(closed) +} + +///| +/// Return a self-contained defensive copy of this schema graph. +pub fn SchemaRef::graph(self : SchemaRef) -> @model.SchemaGraph { + self.owned_graph() +} + +///| +/// Return a defensive copy of this schema root. +pub fn SchemaRef::root(self : SchemaRef) -> @model.SchemaType { + self.owned_graph().root +} + +///| +/// Validate a value and pair it with a self-contained defensive graph copy for +/// fully dynamic invocation. +pub fn SchemaRef::typed_value( + self : SchemaRef, + value : @model.SchemaValue, +) -> @model.TypedSchemaValue raise ReflectError { + self.validate_value(value) + { graph: self.owned_graph(), value, } +} + ///| pub fn SchemaRef::validate_value( self : SchemaRef, diff --git a/sdks/moonbit/golem_sdk/reflection/tool_reflection.mbt b/sdks/moonbit/golem_sdk/reflection/tool_reflection.mbt index 7c993ecd0d..f692a011c5 100644 --- a/sdks/moonbit/golem_sdk/reflection/tool_reflection.mbt +++ b/sdks/moonbit/golem_sdk/reflection/tool_reflection.mbt @@ -1,6 +1,6 @@ ///| /// A selected argument in a discovered tool command's canonical input. -pub(all) struct ToolArgument { +pub struct ToolArgument { kind : String name : String aliases : Array[String] @@ -13,7 +13,7 @@ pub(all) struct ToolArgument { ///| /// A snapshot of one tool registration visible to the calling component. -pub(all) struct ToolType { +pub struct ToolType { lookup_name : String definition : @toolCommon.Tool implemented_by : @types.ComponentId @@ -21,12 +21,12 @@ pub(all) struct ToolType { } derive(Debug, Eq) ///| -pub(all) struct ReflectedToolClient { +pub struct ReflectedToolClient { tool : ToolType } derive(Debug, Eq) ///| -pub(all) struct ToolCommand { +pub struct ToolCommand { tool : ToolType path : Array[String] body : @toolCommon.CommandBody? @@ -37,6 +37,149 @@ pub(all) struct ToolCommand { result : SchemaRef? } derive(Debug, Eq) +///| +pub fn ToolArgument::kind(self : ToolArgument) -> String { + self.kind +} + +///| +pub fn ToolArgument::name(self : ToolArgument) -> String { + self.name +} + +///| +pub fn ToolArgument::aliases(self : ToolArgument) -> Array[String] { + self.aliases.copy() +} + +///| +pub fn ToolArgument::short(self : ToolArgument) -> Char? { + self.short +} + +///| +pub fn ToolArgument::required(self : ToolArgument) -> Bool { + self.required +} + +///| +fn schema_value_contains_capability(value : @model.SchemaValue) -> Bool { + match value { + Record(values) | Tuple(values) | List(values) | FixedList(values) => { + for value in values { + if schema_value_contains_capability(value) { + return true + } + } + false + } + Variant(_, payload) + | Option(payload) + | ResultOk(payload) + | ResultErr(payload) => + match payload { + Some(value) => schema_value_contains_capability(value) + None => false + } + Map(entries) => { + for entry in entries { + if schema_value_contains_capability(entry.key) || + schema_value_contains_capability(entry.value) { + return true + } + } + false + } + Union(_, body) => schema_value_contains_capability(body) + Secret(_) | QuotaToken(_) | Stream(_) | PermissionCard(_) => true + _ => false + } +} + +///| +pub fn ToolArgument::default( + self : ToolArgument, +) -> @model.SchemaValue? raise ReflectError { + match self.default { + Some(value) => { + if schema_value_contains_capability(value) { + raise Schema("tool argument defaults cannot contain capabilities") + } + let encoded = @model.schema_value_to_wit(value) catch { + error => + raise Schema("failed to copy tool argument default: \{repr(error)}") + } + Some( + @model_host.schema_value_from_wit(encoded) catch { + error => + raise Schema("failed to copy tool argument default: \{repr(error)}") + }, + ) + } + None => None + } +} + +///| +pub fn ToolArgument::schema(self : ToolArgument) -> SchemaRef { + self.schema +} + +///| +pub fn ToolType::lookup_name(self : ToolType) -> String { + self.lookup_name +} + +///| +pub fn ToolType::version(self : ToolType) -> String { + self.definition.version +} + +///| +pub fn ToolType::implemented_by(self : ToolType) -> @types.ComponentId { + self.implemented_by +} + +///| +pub fn ReflectedToolClient::tool(self : ReflectedToolClient) -> ToolType { + self.tool +} + +///| +pub fn ToolCommand::tool(self : ToolCommand) -> ToolType { + self.tool +} + +///| +pub fn ToolCommand::path(self : ToolCommand) -> Array[String] { + self.path.copy() +} + +///| +pub fn ToolCommand::subcommands(self : ToolCommand) -> Array[String] { + self.subcommands.copy() +} + +///| +pub fn ToolCommand::arguments(self : ToolCommand) -> Array[ToolArgument] { + self.arguments.copy() +} + +///| +pub fn ToolCommand::input_schema(self : ToolCommand) -> SchemaRef { + self.input_schema +} + +///| +pub fn ToolCommand::result(self : ToolCommand) -> SchemaRef? { + self.result +} + +///| +pub fn ToolCommand::is_callable(self : ToolCommand) -> Bool { + self.body is Some(_) +} + ///| pub(all) struct ReflectedToolCustomError { name : String diff --git a/sdks/moonbit/golem_sdk/reflection/tool_reflection_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/tool_reflection_wbtest.mbt index 988afa2a61..bfe51fd95e 100644 --- a/sdks/moonbit/golem_sdk/reflection/tool_reflection_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/tool_reflection_wbtest.mbt @@ -284,6 +284,44 @@ test "reflected optional tool inputs share the canonical wire schema" { } } +///| +test "tool snapshot accessors cannot mutate later command lookup" { + let tool = sample_tool() + let command = tool.command(["r"]) + let path = command.path() + path[0] = "mutated" + let arguments = command.arguments() + let aliases = arguments[0].aliases() + aliases.push("mutated") + arguments[0] = arguments[1] + let graph = command.input_schema().graph() + guard graph.root.body is Record(fields) else { + fail("expected reflected record input") + } + fields[0] = { ..fields[0], name: "mutated", } + fields[1].metadata.aliases.push("mutated") + + let fresh = tool.command(["r"]) + assert_eq(fresh.path(), ["run"]) + assert_eq(fresh.arguments()[0].name(), "message") + assert_eq(fresh.arguments()[0].aliases(), []) + let fresh_graph = fresh.input_schema().graph() + guard fresh_graph.root.body is Record(fresh_fields) else { + fail("expected reflected record input") + } + assert_eq(fresh_fields[0].name, "message") + assert_eq(fresh_fields[1].metadata.aliases, []) + fresh + .input_schema() + .validate_value( + @model.SchemaValue::Record([ + @model.SchemaValue::String("message"), + @model.SchemaValue::Option(None), + @model.SchemaValue::Option(None), + ]), + ) +} + ///| extern "wasm" fn set_reflection_tool_host_mode(mode : Int) = "golem:test" "set-tool-host-mode" diff --git a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt index 250da70dbc..401585d2df 100644 --- a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt +++ b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt @@ -88,21 +88,20 @@ async fn invoke_discovered_tool_dynamically() -> String { Ok(value) => value Err(error) => return "input:\{describe_dynamic_tool_error(error)}" } - command.input_schema.validate_value(packed) catch { + command.input_schema().validate_value(packed) catch { error => return "input:\{Repr(error)}" } + let typed_input = command.input_schema().typed_value(packed) catch { + error => return "input:\{Repr(error)}" + } - let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name) - let raw = match - dynamic.invoke_value(command.path, { - graph: command.input_schema.graph, - value: packed, - }) { + let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name()) + let raw = match dynamic.invoke_value(command.path(), typed_input) { Ok(raw) => raw Err(error) => return "invoke:\{describe_dynamic_tool_error(error)}" } - match (command.result, raw.result) { + match (command.result(), raw.result) { (None, None) => "ok" (Some(schema), Some(output)) => { schema.validate_value(output.value) catch { @@ -173,20 +172,23 @@ pub async fn MoonBitToolReflectionCaller::round_trip(self : Self) -> String { Ok(value) => value Err(_) => return "input-error" } - command.input_schema.validate_value(packed) catch { + command.input_schema().validate_value(packed) catch { error => return "input-validation:\{Repr(error)}" } + let typed_input = command.input_schema().typed_value(packed) catch { + error => return "input-validation:\{Repr(error)}" + } let invalid = command.pack_json(Json::object({ "label": Json::number(42.0) })) is Err(_) let native = command.invoke_value(packed) let encoded = command.invoke_json(input) - let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name).invoke_value( - command.path, - { graph: command.input_schema.graph, value: packed, }, + let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name()).invoke_value( + command.path(), + typed_input, ) let typed = @reflection.ToolClientDefinition::unnamed(fn(name) { MoonbitReflectionTestClient::new_for(name) - }).client_for(tool.lookup_name) catch { + }).client_for(tool.lookup_name()) catch { error => return "typed-definition:\{Repr(error)}" } defer typed.drop() @@ -209,7 +211,7 @@ pub async fn MoonBitToolReflectionCaller::round_trip(self : Self) -> String { } let dynamic_text = match dynamic { Ok(result) => - match (command.result, result.result) { + match (command.result(), result.result) { (Some(schema), Some(value)) => { schema.validate_value(value.value) catch { _ => { From 225347937e68483882e5209b906b0f74cad2c682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 01:55:31 +0200 Subject: [PATCH 03/37] rust: cover reflected output completion failures --- .../rust/golem-rust/src/agentic/reflection.rs | 204 ++++++++++++++---- 1 file changed, 167 insertions(+), 37 deletions(-) diff --git a/sdks/rust/golem-rust/src/agentic/reflection.rs b/sdks/rust/golem-rust/src/agentic/reflection.rs index 2613b42d6e..c39632ba62 100644 --- a/sdks/rust/golem-rust/src/agentic/reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/reflection.rs @@ -520,7 +520,7 @@ impl AgentType { )?; Ok(ReflectedAgentClient { agent_type: self.clone(), - transport: Rc::new(transport), + transport: ReflectedTransport::Host(Rc::new(transport)), reusable_identity: Some(agent_id.clone()), }) } @@ -860,7 +860,7 @@ impl ReflectedAgentClientFactory { )?; Ok(ReflectedAgentClient { agent_type: self.agent_type.clone(), - transport: Rc::new(transport), + transport: ReflectedTransport::Host(Rc::new(transport)), reusable_identity: identity, }) } @@ -869,7 +869,7 @@ impl ReflectedAgentClientFactory { #[derive(Clone)] pub struct ReflectedAgentClient { agent_type: AgentType, - transport: Rc, + transport: ReflectedTransport, reusable_identity: Option, } @@ -898,7 +898,101 @@ impl ReflectedAgentClient { #[derive(Clone)] pub struct ReflectedAgentMethod { definition: AgentMethod, - transport: Rc, + transport: ReflectedTransport, +} + +#[derive(Clone)] +enum ReflectedTransport { + Host(Rc), + #[cfg(test)] + Test(Rc), +} + +#[cfg(test)] +struct TestReflectedTransport { + value: Option, +} + +#[cfg(test)] +impl TestReflectedTransport { + fn completion(&self) -> Invocation> { + Invocation { + metadata: InvocationMetadata { + agent_id: ParsedAgentId::new("test-agent"), + idempotency_key: "test-key".to_string(), + }, + value: self.value.clone(), + } + } + + async fn invoke_and_await(&self, _method: &str, _input: SchemaValue) -> PendingResult { + Ok(self.completion()) + } + + fn pending(&self, _method: &str, _input: SchemaValue) -> ReflectedTransportPending { + let completion = self.completion(); + ReflectedTransportPending { + metadata: completion.metadata.clone(), + cancel: Rc::new(|| {}), + future: Box::pin(std::future::ready(Ok(completion))), + } + } +} + +impl ReflectedTransport { + async fn invoke_and_await(&self, method: &str, input: SchemaValue) -> PendingResult { + match self { + Self::Host(transport) => transport.invoke_and_await(method, input).await, + #[cfg(test)] + Self::Test(transport) => transport.invoke_and_await(method, input).await, + } + } + + fn pending( + &self, + method: &str, + input: SchemaValue, + ) -> Result { + match self { + Self::Host(transport) => { + let pending = transport.pending(method, input)?; + let metadata = pending.metadata.clone(); + let cancellation = Rc::clone(&pending.raw); + Ok(ReflectedTransportPending { + metadata, + cancel: Rc::new(move || cancellation.cancel()), + future: Box::pin(pending), + }) + } + #[cfg(test)] + Self::Test(transport) => Ok(transport.pending(method, input)), + } + } + + fn trigger( + &self, + method: &str, + input: SchemaValue, + ) -> Result { + match self { + Self::Host(transport) => transport.trigger(method, input), + #[cfg(test)] + Self::Test(_) => panic!("test reflected transport does not support trigger"), + } + } + + fn schedule( + &self, + at: ScheduledTime, + method: &str, + input: SchemaValue, + ) -> Result { + match self { + Self::Host(transport) => transport.schedule(at, method, input), + #[cfg(test)] + Self::Test(_) => panic!("test reflected transport does not support scheduling"), + } + } } fn validate_declared_output( @@ -1127,6 +1221,21 @@ pub struct ScheduledInvocation { } type PendingResult = Result>, GolemReflectError>; +type PendingResultFuture = Pin>>; + +struct ReflectedTransportPending { + metadata: InvocationMetadata, + cancel: Rc, + future: PendingResultFuture, +} + +impl Future for ReflectedTransportPending { + type Output = PendingResult; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.get_mut().future.as_mut().poll(cx) + } +} pub struct PendingInvocation { pub metadata: InvocationMetadata, @@ -1136,7 +1245,7 @@ pub struct PendingInvocation { /// A reflected pending invocation that applies the selected method's output policy on completion. pub struct ReflectedPendingInvocation { - checked: CheckedReflectedOutput, + checked: CheckedReflectedOutput, } struct CheckedReflectedOutput { @@ -1151,7 +1260,7 @@ impl ReflectedPendingInvocation { } pub fn cancel(&self) { - self.checked.inner.cancel(); + (self.checked.inner.cancel)(); } pub async fn get(self) -> PendingResult { @@ -1570,17 +1679,16 @@ fn decode_custom_error(value: crate::schema::wit::wire::TypedSchemaValue) -> Rem #[cfg(test)] mod tests { use super::{ - CheckedReflectedOutput, GolemReflectError, Invocation, InvocationMetadata, - MethodOnlyAgentClientDefinition, ParsedAgentId, SchemaRef, validate_declared_output, + AgentMethod, GolemReflectError, MethodOnlyAgentClientDefinition, ReflectedAgentMethod, + ReflectedTransport, SchemaRef, TestReflectedTransport, validate_declared_output, }; + use crate::bindings::golem::agent::common as wire_common; use crate::schema::{ MetadataEnvelope, NamedFieldType, SchemaGraph, SchemaType, SchemaValue, VariantCaseType, VariantValuePayload, }; use serde_json::json; - use std::future::{Future, ready}; - use std::pin::Pin; - use std::task::{Context, Poll, Waker}; + use std::rc::Rc; use test_r::test; #[test] @@ -1666,39 +1774,61 @@ mod tests { } #[test] - fn reflected_pending_output_matches_awaited_policy() { + async fn reflected_public_completion_paths_apply_the_same_output_policy() { let output = SchemaRef::new(SchemaGraph::anonymous(SchemaType::string())); - for (declared, value) in [ - (Some(output.clone()), None), - (None, Some(SchemaValue::Bool(true))), - (Some(output.clone()), Some(SchemaValue::U32(7))), + let input = SchemaRef::new(SchemaGraph::anonymous(SchemaType::record(vec![]))); + for (name, declared, value) in [ + ("missing_declared", Some(output.clone()), None), + ("unexpected_unit", None, Some(SchemaValue::Bool(true))), ( + "incompatible", Some(output.clone()), - Some(SchemaValue::String("ok".to_string())), + Some(SchemaValue::U32(7)), ), ] { - let awaited = validate_declared_output(declared.as_ref(), value.as_ref(), "read"); - let invocation = Invocation { - metadata: InvocationMetadata { - agent_id: ParsedAgentId::new("test"), - idempotency_key: "test".to_string(), + let method = ReflectedAgentMethod { + definition: AgentMethod { + agent_type_name: "InvalidOutput".to_string(), + raw: wire_common::AgentMethod { + name: name.to_string(), + description: String::new(), + http_endpoint: vec![], + prompt_hint: None, + input_schema: wire_common::InputSchema::Parameters(vec![]), + output_schema: if declared.is_some() { + wire_common::OutputSchema::Single(0) + } else { + wire_common::OutputSchema::Unit + }, + read_only: None, + }, + input: input.clone(), + output: declared, }, - value, - }; - let mut pending = CheckedReflectedOutput { - inner: ready(Ok(invocation)), - output: declared, - method: "read".to_string(), - }; - let mut context = Context::from_waker(Waker::noop()); - let completed = Pin::new(&mut pending).poll(&mut context); - let Poll::Ready(result) = completed else { - panic!("ready reflected invocation must complete"); + transport: ReflectedTransport::Test(Rc::new(TestReflectedTransport { value })), }; - assert_eq!(result.is_ok(), awaited.is_ok()); - if let Err(error) = result { - assert!(matches!(error, GolemReflectError::MalformedRemoteOutput(_))); - } + let empty = SchemaValue::Record { fields: vec![] }; + + let awaited = method + .invoke_value(empty.clone()) + .await + .expect_err("awaited completion must reject malformed output"); + let pending = method + .pending_value(empty) + .expect("pending invocation starts") + .get() + .await + .expect_err("pending completion must reject malformed output"); + + assert!(matches!( + awaited, + GolemReflectError::MalformedRemoteOutput(_) + )); + assert!(matches!( + pending, + GolemReflectError::MalformedRemoteOutput(_) + )); + assert_eq!(awaited.to_string(), pending.to_string()); } } From ac3aac46846362949867cf70323995d3123edaff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 02:48:35 +0200 Subject: [PATCH 04/37] scala: cover fallible generated tool construction --- .../tests/app/scala_guest_streams.rs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/cli/golem-cli/tests/app/scala_guest_streams.rs b/cli/golem-cli/tests/app/scala_guest_streams.rs index bba48b14b2..4e713c30f7 100644 --- a/cli/golem-cli/tests/app/scala_guest_streams.rs +++ b/cli/golem-cli/tests/app/scala_guest_streams.rs @@ -119,12 +119,19 @@ async fn deployed_scala_streams_context() -> TestContext { fs::write_str(scala_dir.join("consumer/StreamConsumer.scala"), indoc! {r#" package consumer import golem.BaseAgent - import golem.runtime.annotations.{agentDefinition, agentImplementation} + import golem.reflection.{GolemReflectError, ToolClientDefinition} + import golem.runtime.annotations.{agentDefinition, agentImplementation, toolDefinition} import golem.schema.AgentStream + import golem.tool.ToolRpcFailure import golem.bridge.client.stream_provider.{StreamProviderClient, Item, Bundle} import scala.concurrent.Future import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue + @toolDefinition(name = "scala-construction-probe") + trait ConstructionProbeTool { + def ping(): String + } + @agentDefinition() trait StreamConsumer extends BaseAgent { class Id(val name: String) @@ -133,6 +140,7 @@ async fn deployed_scala_streams_context() -> TestContext { def cancel(): Future[String] def recoverable(): Future[String] def fatal(): Future[String] + def invalidToolTarget(): String } @agentImplementation() final class StreamConsumerImpl(private val name: String) extends StreamConsumer { @@ -214,6 +222,17 @@ async fn deployed_scala_streams_context() -> TestContext { val provider = StreamProviderClient.get(name + "-fatal") provider.malformed().flatMap(collect).map(_ => "unexpected-clean-eof") } + def invalidToolTarget(): String = { + val definition = ToolClientDefinition.named("NOT-valid")( + (target: String) => ConstructionProbeToolClient(target) + ) + definition.client match { + case Left(GolemReflectError.ToolRpc(ToolRpcFailure.ProtocolError(message))) => + "scala-invalid-tool-recovered:" + message + case Left(error) => "unexpected-error:" + error.toString + case Right(_) => "unexpected-success" + } + } } "#}).unwrap(); // Guest bridge sources belong to the consumer, not the provider's discovery input. @@ -243,6 +262,17 @@ async fn test_scala_agent_guest_streams_e2e() { .await; assert!(output.success_or_dump()); assert!(output.stdout_contains("scala-streams-ok")); + let invalid_target = ctx + .cli([ + flag::YES, + cmd::AGENT, + cmd::INVOKE, + "StreamConsumer(\"test\")", + "invalidToolTarget", + ]) + .await; + assert!(invalid_target.success_or_dump()); + assert!(invalid_target.stdout_contains("scala-invalid-tool-recovered:")); for (method, expected) in [("cancel", "scala-cancel-ok"), ("nested", "scala-nested-ok")] { let output = tokio::time::timeout( std::time::Duration::from_secs(120), From 97230d6c3a9e0f04a9694e4710ac33e8066cd063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 03:03:06 +0200 Subject: [PATCH 05/37] docs: simplify reflection client approaches --- docs/src/content/next/concepts/_meta.js | 1 + .../next/concepts/agent-reflection.mdx | 82 +++++++++++++++++++ docs/src/content/next/develop/rpc.mdx | 2 +- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 docs/src/content/next/concepts/agent-reflection.mdx diff --git a/docs/src/content/next/concepts/_meta.js b/docs/src/content/next/concepts/_meta.js index a4ead63268..e7fd68500d 100644 --- a/docs/src/content/next/concepts/_meta.js +++ b/docs/src/content/next/concepts/_meta.js @@ -3,6 +3,7 @@ export default { agents: "Agents", "worker-gateway": "API Gateway", "agent-to-agent-communication": "Agent to Agent Communication", + "agent-reflection": "Agent Reflection", "api-definitions": "API Definitions", plugins: "Plugins", } diff --git a/docs/src/content/next/concepts/agent-reflection.mdx b/docs/src/content/next/concepts/agent-reflection.mdx new file mode 100644 index 0000000000..9924c5bcb2 --- /dev/null +++ b/docs/src/content/next/concepts/agent-reflection.mdx @@ -0,0 +1,82 @@ +# Agent reflection + +Golem offers Normal RPC plus three reflection approaches for calling agents and +tools. Choose the simplest approach whose source of schemas matches your +application. + +| Client approach | Use it when | What validates the call | +| --- | --- | --- | +| **Normal RPC** | Producer and caller share the same source definition | Generated static codecs validate typed inputs and decode outputs | +| **Caller-defined static client** | The caller knows a stable interface but does not share the producer's source definition | Caller-owned schemas and codecs validate the fields they describe | +| **Discovered client** | The caller selects an agent, method, tool, or command from the current environment | An immutable discovery snapshot automatically validates inputs and declared outputs | +| **Fully dynamic client** | Infrastructure already transports packed schema-native values | The caller explicitly owns packing and validation; the host still validates authorization and the deployed target | + +Normal RPC is not reflection. Prefer it for ordinary application code with a +shared definition: it provides the strongest compile-time experience with the +least setup. + +Caller-defined static clients are useful for integrations that deliberately +own a typed view of another component. A full agent definition can create and +identify agents and validate known configuration declarations. A method-only +definition can bind an existing durable identity, but cannot validate an +identity or configuration declarations it does not describe. Caller-defined +tool definitions similarly provide typed access to a selected tool surface. + +Discovered clients are the default choice when the deployed environment is the +source of truth. Discovery reads visible metadata and returns a client backed +by that snapshot. The snapshot does not change under the caller: rediscover +explicitly when a newer deployment should be used. + +Fully dynamic clients are the lowest-level option. They invoke an existing +durable agent identity or an explicit tool command using schema-native values. +Use them for gateways, adapters, and other infrastructure that intentionally +keeps schema selection, packing, and result validation separate from +transport. + +## Validation boundaries + +Static and discovered clients validate what they know before opening RPC. +Discovered schemas include numeric and text restrictions, nested structures, +defaults, and tool command constraints. Reflected JSON helpers provide +canonical JSON conversion, and JSON Schema projections make the same shape +available to JSON-based integrations. Streams and opaque capabilities cannot +be represented as JSON and must use schema-native value APIs. + +The host remains authoritative for visibility, authorization, effective +configuration, durable identity resolution, and the deployed target schema. +Awaited calls also verify whether an output is required and whether the +returned value matches the declared schema. Remote agent failures, tool +failures, and custom error payloads remain structured so callers can handle +them without parsing messages. + +Configuration overrides apply when an agent is created. Rebinding an existing +durable identity does not reconfigure that agent; it keeps its persisted +initial configuration. + +## Composing approaches + +The approaches can meet at schemas and identities: + +- Discovery can provide schemas to an explicit JSON or JSON Schema adapter. +- A discovered schema can pack input for a fully dynamic call and validate its + result, but moving the value into a dynamic client does not carry the + discovered client's automatic validation policy with it. +- Normal RPC, full caller-defined clients, and discovered factories can + produce a durable identity. That identity can later be rebound with a + method-only, discovered, or fully dynamic client. +- Invocation metadata can be stored, parsed, explicitly rediscovered, and + rebound when the identity is durable and reusable. + +Ephemeral agents form a hard boundary. A known phantom can be invoked once and +returns its final identity in invocation metadata, but that final ephemeral +identity cannot be rebound for another call. Started dynamic or reflected +operations also retain their normal resource rules: consume returned streams, +and cancel or close work that the caller abandons. + +For concrete APIs and language recipes, see the [common reflection +guide](/next/how-to-guides/common/golem-agent-reflection) and the SDK guides for +[Rust](/next/how-to-guides/rust/golem-agent-reflection-rust), +[TypeScript](/next/how-to-guides/ts/golem-agent-reflection-ts), +[Effect](/next/how-to-guides/effect/golem-agent-reflection-effect), +[Scala](/next/how-to-guides/scala/golem-agent-reflection-scala), and +[MoonBit](/next/how-to-guides/moonbit/golem-agent-reflection-moonbit). diff --git a/docs/src/content/next/develop/rpc.mdx b/docs/src/content/next/develop/rpc.mdx index 2099c1b500..55c4db54e2 100644 --- a/docs/src/content/next/develop/rpc.mdx +++ b/docs/src/content/next/develop/rpc.mdx @@ -4,7 +4,7 @@ import { Tabs } from "nextra/components" See the [Agent to Agent communication page](/next/concepts/agent-to-agent-communication) for a general overview of how agents can invoke each other in Golem. -## Code-first approach +## Normal RPC When defining an _agent_ in code, this automatically defines a client interface for calling remote agents as well. This way an agent of an agent type can create other agents and invoke methods on them. The target agent can be of any agent type available for the caller agent in code. From d00ed1d8d2d3e7e3b9b291686e734727e4b8f04f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 14:41:42 +0200 Subject: [PATCH 06/37] schema: support minimum quantity mantissas --- golem-schema/src/schema/canonical/quantity.rs | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/golem-schema/src/schema/canonical/quantity.rs b/golem-schema/src/schema/canonical/quantity.rs index be02695c53..3e762bd535 100644 --- a/golem-schema/src/schema/canonical/quantity.rs +++ b/golem-schema/src/schema/canonical/quantity.rs @@ -22,10 +22,9 @@ //! `from_text` additionally accepts a single ASCII space between the //! decimal and the unit (e.g. `1 kg`); the output form is always //! no-space. -//! - Text form is restricted to `|scale| <= 18` and rejects -//! `mantissa == i64::MIN`; both would either overflow representation or -//! produce an unbounded output string. JSON encoding is unrestricted -//! on both fronts. A negative-scale rendering whose absolute decimal +//! - Text form is restricted to `|scale| <= 18`; larger scales would produce +//! an unbounded output string. JSON encoding is unrestricted on this front. +//! A negative-scale rendering whose absolute decimal //! string would exceed 40 characters is rejected as //! `ParseError::OutOfRange("quantity scale")`. //! - JSON form: `{ "mantissa": "…", "scale": …, "unit": "..." }`, with @@ -51,9 +50,6 @@ fn unit_regex() -> &'static Regex { } pub fn to_text(payload: &QuantityValue) -> Result { - if payload.mantissa == i64::MIN { - return Err(ParseError::OutOfRange("quantity mantissa")); - } if payload.scale.unsigned_abs() > MAX_ABS_SCALE_TEXT as u32 { return Err(ParseError::OutOfRange("quantity scale")); } @@ -263,12 +259,12 @@ fn parse_decimal(s: &str) -> Result<(i64, i32), ParseError> { let combined: String = format!("{whole}{frac}"); let stripped = combined.trim_start_matches('0'); let digits = if stripped.is_empty() { "0" } else { stripped }; - let magnitude: i64 = digits + let magnitude: i128 = digits .parse() .map_err(|_| ParseError::OutOfRange("mantissa"))?; - let mut mantissa = sign - .checked_mul(magnitude) - .ok_or(ParseError::OutOfRange("mantissa"))?; + let mut mantissa: i64 = (i128::from(sign) * magnitude) + .try_into() + .map_err(|_| ParseError::OutOfRange("mantissa"))?; let mut scale: i32 = frac.len() as i32; while scale > 0 && mantissa % 10 == 0 && mantissa != 0 { mantissa /= 10; @@ -285,8 +281,7 @@ fn format_decimal(mantissa: i64, scale: i32) -> String { return "0".to_string(); } let negative = mantissa < 0; - // `to_text` rejects `i64::MIN` before reaching here, so `.abs()` is safe. - let abs_str = mantissa.abs().to_string(); + let abs_str = mantissa.unsigned_abs().to_string(); let body = if scale <= 0 { let mut s = abs_str; for _ in 0..(-scale) { @@ -547,15 +542,14 @@ mod tests { } #[test] - fn i64_min_mantissa_text_rejected() { + fn i64_min_mantissa_text_roundtrips() { let p = QuantityValue { mantissa: i64::MIN, scale: 0, unit: "x".into(), }; - assert_eq!( - to_text(&p), - Err(ParseError::OutOfRange("quantity mantissa")) - ); + let text = to_text(&p).unwrap(); + assert_eq!(text, "-9223372036854775808x"); + assert_eq!(from_text(&text), Ok(p)); } } From b0b2ee037d51c334415e9129875d318f5a9498ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 14:41:55 +0200 Subject: [PATCH 07/37] schema: accept omitted optional record fields --- golem-schema/src/schema/render/json_value.rs | 40 ++++++++++++++++---- golem-worker-service/src/mcp/invoke/mod.rs | 19 ++++++---- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/golem-schema/src/schema/render/json_value.rs b/golem-schema/src/schema/render/json_value.rs index a91fa8af22..27a35a1615 100644 --- a/golem-schema/src/schema/render/json_value.rs +++ b/golem-schema/src/schema/render/json_value.rs @@ -674,13 +674,24 @@ fn from_json_body( } let mut out = Vec::with_capacity(fields.len()); for field in fields.iter() { - let value = obj.get(&field.name).ok_or_else(|| { - mismatch(path, format!("missing record field `{}`", field.name)) - })?; - path.push(PathSegment::Field(field.name.clone())); - let v = from_json_inner(graph, &field.body, value, path, &mut visited, policy)?; - path.pop(); - out.push(v); + match obj.get(&field.name) { + Some(value) => { + path.push(PathSegment::Field(field.name.clone())); + let v = + from_json_inner(graph, &field.body, value, path, &mut visited, policy)?; + path.pop(); + out.push(v); + } + None if resolves_to_option(graph, &field.body) => { + out.push(SchemaValue::Option { inner: None }); + } + None => { + return Err(mismatch( + path, + format!("missing record field `{}`", field.name), + )); + } + } } Ok(SchemaValue::Record { fields: out }) } @@ -878,6 +889,21 @@ fn from_json_body( } } +fn resolves_to_option(graph: &SchemaGraph, ty: &SchemaType) -> bool { + let mut current = ty; + let mut visited = HashSet::new(); + loop { + match current { + SchemaType::Option { .. } => return true, + SchemaType::Ref { id, .. } if visited.insert(id.clone()) => match graph.lookup(id) { + Some(definition) => current = &definition.body, + None => return false, + }, + _ => return false, + } + } +} + fn decode_result( graph: &SchemaGraph, spec: &ResultSpec, diff --git a/golem-worker-service/src/mcp/invoke/mod.rs b/golem-worker-service/src/mcp/invoke/mod.rs index 7ae6b988d6..13c1d1a8b5 100644 --- a/golem-worker-service/src/mcp/invoke/mod.rs +++ b/golem-worker-service/src/mcp/invoke/mod.rs @@ -194,19 +194,24 @@ mod codec_tests { } #[test] - fn option_record_field_is_required() { + fn option_record_field_may_be_omitted() { let ty = SchemaType::record(vec![field( "inner", SchemaType::option(SchemaType::string()), )]); - // The advertised schema marks the field required; an explicit null is - // accepted and round-trips. + // The advertised schema omits the field from `required`; omission and + // explicit null both decode as `None`. round_trip(ty.clone(), json!({"inner": null})); round_trip(ty.clone(), json!({"inner": "x"})); - // Omitting the field is rejected (schema and runtime now agree). - assert!( - parse_fails(ty, json!({})), - "omitted option record field must be rejected" + let graph = SchemaGraph::anonymous(ty); + let value = from_json_value(&graph, &graph.root, &json!({})).expect("omitted option field"); + assert_eq!( + value, + golem_common::schema::schema_value::SchemaValue::Record { + fields: vec![golem_common::schema::schema_value::SchemaValue::Option { + inner: None, + }], + } ); } } From d96a6ae3e46006888ef682c42c7d2e26b079b291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 14:42:07 +0200 Subject: [PATCH 08/37] reflection: reject unsupported JSON schema leaves --- golem-schema/src/schema/render/json_schema.rs | 54 ++++++++++++++++--- golem-schema/src/schema/render/mod.rs | 2 +- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/golem-schema/src/schema/render/json_schema.rs b/golem-schema/src/schema/render/json_schema.rs index ab265e0821..57f7a54875 100644 --- a/golem-schema/src/schema/render/json_schema.rs +++ b/golem-schema/src/schema/render/json_schema.rs @@ -37,6 +37,7 @@ pub struct JsonSchemaConfig { /// Emit the `$schema` JSON Schema draft marker at the document root. pub include_draft_marker: bool, host_managed: HostManagedSchemaPolicy, + unsupported: UnsupportedLeafSchemaPolicy, } #[derive(Clone, Copy, Debug)] @@ -46,12 +47,19 @@ enum HostManagedSchemaPolicy { Redact, } +#[derive(Clone, Copy, Debug)] +enum UnsupportedLeafSchemaPolicy { + Placeholder, + Reject, +} + impl JsonSchemaConfig { /// Canonical standalone JSON Schema document (includes the `$schema` /// draft marker). pub const CANONICAL: Self = Self { include_draft_marker: true, host_managed: HostManagedSchemaPolicy::TrustedSnapshot, + unsupported: UnsupportedLeafSchemaPolicy::Placeholder, }; /// Canonical JSON Schema document without the `$schema` draft marker, for @@ -59,16 +67,27 @@ impl JsonSchemaConfig { pub const WITHOUT_DRAFT_MARKER: Self = Self { include_draft_marker: false, host_managed: HostManagedSchemaPolicy::TrustedSnapshot, + unsupported: UnsupportedLeafSchemaPolicy::Placeholder, }; pub(crate) const EXTERNAL_INPUT: Self = Self { include_draft_marker: false, host_managed: HostManagedSchemaPolicy::Reject, + unsupported: UnsupportedLeafSchemaPolicy::Placeholder, }; pub(crate) const EXTERNAL_OUTPUT: Self = Self { include_draft_marker: false, host_managed: HostManagedSchemaPolicy::Redact, + unsupported: UnsupportedLeafSchemaPolicy::Placeholder, + }; + + /// Reflection JSON adapters cannot pack or unpack capabilities, futures, + /// or streams, so their projected schemas must reject every JSON value. + pub const REFLECTION: Self = Self { + include_draft_marker: true, + host_managed: HostManagedSchemaPolicy::Reject, + unsupported: UnsupportedLeafSchemaPolicy::Reject, }; } @@ -158,6 +177,24 @@ pub fn to_external_output_json_schema( ) } +/// Render the JSON domain accepted by reflection `pack_json`/`unpack_json`. +/// Capability, future, and stream leaves are unsatisfiable because those +/// adapters have no JSON representation for them. +pub fn to_reflection_json_schema( + graph: &SchemaGraph, + ty: &SchemaType, + include_draft_marker: bool, +) -> Value { + to_json_schema_with_config( + graph, + ty, + JsonSchemaConfig { + include_draft_marker, + ..JsonSchemaConfig::REFLECTION + }, + ) +} + /// Whether `ty`, after following any `Ref` chain against `graph`, is an /// `option<…>`. Used to decide whether an input parameter is required. fn resolves_to_option(graph: &SchemaGraph, ty: &SchemaType) -> bool { @@ -809,13 +846,16 @@ pub(super) fn render_type( }) } - SchemaType::Future { .. } | SchemaType::Stream { .. } => obj([ - ("type", Value::String("null".to_string())), - ( - "description", - Value::String("WASI P3 placeholder".to_string()), - ), - ]), + SchemaType::Future { .. } | SchemaType::Stream { .. } => match config.unsupported { + UnsupportedLeafSchemaPolicy::Placeholder => obj([ + ("type", Value::String("null".to_string())), + ( + "description", + Value::String("WASI P3 placeholder".to_string()), + ), + ]), + UnsupportedLeafSchemaPolicy::Reject => obj([("not", Value::Object(Map::new()))]), + }, }; // Per-node metadata: attach docs / examples / deprecated for every diff --git a/golem-schema/src/schema/render/mod.rs b/golem-schema/src/schema/render/mod.rs index ffaf622cd1..6768a50879 100644 --- a/golem-schema/src/schema/render/mod.rs +++ b/golem-schema/src/schema/render/mod.rs @@ -23,7 +23,7 @@ pub mod walker; pub use error::RenderError; pub use json_schema::{ JsonSchemaConfig, to_external_input_json_schema, to_external_output_json_schema, - to_json_schema, to_json_schema_with_config, + to_json_schema, to_json_schema_with_config, to_reflection_json_schema, }; pub use json_value::{ from_json_value, from_untrusted_json_value, to_json_value, to_json_value_redacted, From a1705b56a4a8e2474476d2284fb2cd2c06e3fe38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 14:42:22 +0200 Subject: [PATCH 09/37] typescript: align reflection JSON semantics --- .../src/internal/reflection/schemaRender.ts | 13 ++++++++----- sdks/effect/test/schema-ref.test.ts | 18 +++++++++++++++--- .../packages/golem-ts-sdk/src/schema/render.ts | 14 +++++++++----- .../golem-ts-sdk/tests/schema-ref.test.ts | 18 +++++++++++++++--- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/sdks/effect/src/internal/reflection/schemaRender.ts b/sdks/effect/src/internal/reflection/schemaRender.ts index 461ab5ae3d..2def2bdc03 100644 --- a/sdks/effect/src/internal/reflection/schemaRender.ts +++ b/sdks/effect/src/internal/reflection/schemaRender.ts @@ -154,7 +154,10 @@ export function fromCanonicalJson( return { tag: "record", fields: body.fields.map((field) => { - if (!(field.name in object)) fail([...path, field.name], "missing field") + if (!(field.name in object)) { + if (resolve(graph, field.body).body.tag === "option") return { tag: "option" } + fail([...path, field.name], "missing field") + } return fromCanonicalJson(graph, field.body, object[field.name], [...path, field.name]) }), } @@ -625,7 +628,9 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record field.name), + required: body.fields + .filter((field) => resolve(graph, field.body).body.tag !== "option") + .map((field) => field.name), additionalProperties: false, } break @@ -713,11 +718,9 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record { }) }) - it("renders numeric restrictions and required nullable fields", () => { + it("renders numeric restrictions and optional nullable fields", () => { const schema = ref( t.record([ field( @@ -46,10 +46,10 @@ describe("SchemaRef", () => { ]), ) expect(schema.toJsonSchema()).toMatchObject({ - required: ["bounded", "nullable"], + required: ["bounded"], properties: { bounded: { type: "integer", minimum: 10, maximum: 20 } }, }) - expect(schema.validateJson({ bounded: 15 }).success).toBe(false) + expect(schema.validateJson({ bounded: 15 }).success).toBe(true) expect(schema.validateJson({ bounded: 15, nullable: null }).success).toBe(true) for (const numeric of [t.f32, t.f64]) { expect( @@ -190,6 +190,18 @@ describe("SchemaRef", () => { }) }) + it("makes reflection-only unsupported leaves unsatisfiable", () => { + for (const type of [ + t.secret(t.string()), + t.quotaToken({}), + t.permissionCard({ polymorphic: false }), + t.future(t.string()), + t.stream(t.string()), + ]) { + expect(ref(type).toJsonSchema()).toMatchObject({ not: {} }) + } + }) + it("distinguishes native float membership from canonical JSON representability", () => { for (const [numeric, tag] of [ [t.f32, "f32-value"], diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts index 6bf7e90aab..0936e082b2 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts @@ -99,7 +99,10 @@ export function fromCanonicalJson( return { tag: 'record', fields: body.fields.map((field) => { - if (!(field.name in object)) fail([...path, field.name], 'missing field'); + if (!(field.name in object)) { + if (resolve(graph, field.body).body.tag === 'option') return { tag: 'option' }; + fail([...path, field.name], 'missing field'); + } return fromCanonicalJson(graph, field.body, object[field.name], [...path, field.name]); }), }; @@ -233,6 +236,7 @@ export function toCanonicalJson( fail(path, `expected ${body.tag} schema value, found ${value.tag}`); switch (value.tag) { case 'bool': + return value.value; case 's8': case 's16': case 's32': @@ -570,7 +574,9 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record field.name), + required: body.fields + .filter((field) => resolve(graph, field.body).body.tag !== 'option') + .map((field) => field.name), additionalProperties: false, }; break; @@ -658,11 +664,9 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record { - it('requires an explicit null for an absent option in a record', () => { + it('decodes omitted and explicit-null option fields as absent', () => { const ref = schema(t.record([field('maybe', t.option(t.string()))])); - expect(ref.validateJson({}).success).toBe(false); + expect(ref.packJson({})).toEqual(v.record([v.option()])); expect(ref.packJson({ maybe: null })).toEqual(v.record([v.option()])); }); @@ -162,7 +162,7 @@ describe('SchemaRef JSON Schema', () => { name: { type: 'string', description: 'Display name', examples: ['Ada'] }, nickname: { oneOf: [{ type: 'null' }, { type: 'string' }] }, }, - required: ['name', 'nickname'], + required: ['name'], additionalProperties: false, }, }, @@ -226,4 +226,16 @@ describe('SchemaRef JSON Schema', () => { 'x-golem-maximum': '18446744073709551615', }); }); + + it('makes reflection-only unsupported leaves unsatisfiable', () => { + for (const type of [ + t.secret(t.string()), + t.quotaToken({}), + t.permissionCard({ polymorphic: false }), + schemaType({ tag: 'future', element: t.string() }), + t.stream(t.string()), + ]) { + expect(schema(type).toJsonSchema()).toMatchObject({ not: {} }); + } + }); }); From 01a31a54e24349b7207fcf81da38fdc4faa4cfc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 14:42:42 +0200 Subject: [PATCH 10/37] scala: use lossless canonical JSON for wide values --- .../scala/golem/reflection/SchemaRef.scala | 166 +++++++++--------- .../golem/reflection/SchemaRefSpec.scala | 113 +++++++++++- .../GolemExamplesIntegrationSpec.scala | 7 +- .../main/scala/golem/schema/Derivation.scala | 37 +++- .../scala/golem/schema/RichTypesSpec.scala | 14 ++ .../reflection/repl-reflection-canonical.ts | 3 + .../ToolReflectionCaller.scala | 71 +++++++- 7 files changed, 308 insertions(+), 103 deletions(-) create mode 100644 sdks/scala/test-agents/samples/reflection/repl-reflection-canonical.ts diff --git a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala index 2c51fcbc29..11667a6d9e 100644 --- a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala @@ -52,7 +52,9 @@ object SchemaRef { } private object CanonicalJson { - private val MaxSafeInteger = BigInt("9007199254740991") + private val SignedIntegerPattern = "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$".r + private val UnsignedIntegerPattern = "^(?:0|[1-9][0-9]*)$".r + private val U64Max = (BigInt(1) << 64) - 1 def pack(graph: SchemaGraph, schema: SchemaType, json: Json): Either[SchemaIssue, SchemaValue] = attempt(packUnsafe(graph, resolve(graph, schema), json)) @@ -97,26 +99,42 @@ private object CanonicalJson { } typed("number", limits: _*) } + def wideInteger(unsigned: Boolean): Json = + typed( + "string", + "format" -> Json.String(if (unsigned) "uint64" else "int64"), + "pattern" -> Json.String( + if (unsigned) "^(?:0|[1-9][0-9]*)$" else "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$" + ), + "x-golem-minimum" -> Json.String(if (unsigned) "0" else Long.MinValue.toString), + "x-golem-maximum" -> Json.String(if (unsigned) U64Max.toString else Long.MaxValue.toString) + ) + def isOption(schema: SchemaType): Boolean = resolve(graph, schema).body match { + case OptionType(_) => true + case _ => false + } schema.body match { case RefType(id) => Json.Object("$ref" -> Json.String(s"#/$$defs/${id.replace("~", "~0").replace("/", "~1")}")) case BoolType => typed("boolean") case S8Type(r) => integer(-128, 127, r) case S16Type(r) => integer(-32768, 32767, r) case S32Type(r) => integer(Int.MinValue, Int.MaxValue, r) - case S64Type(r) => integer(BigInt(Long.MinValue), BigInt(Long.MaxValue), r) + case S64Type(_) => wideInteger(unsigned = false) case U8Type(r) => integer(0, 255, r) case U16Type(r) => integer(0, 65535, r) case U32Type(r) => integer(0, BigInt("4294967295"), r) - case U64Type(r) => integer(0, (BigInt(1) << 64) - 1, r) + case U64Type(_) => wideInteger(unsigned = true) case F32Type(r) => decimal(r) case F64Type(r) => decimal(r) case CharType => typed("string", "minLength" -> number(1), "maxLength" -> number(1)) case StringType => typed("string") case RecordType(recordFields) => Json.Object( - "type" -> Json.String("object"), - "properties" -> Json.Object(recordFields.map(field => field.name -> schemaJson(graph, field.body)): _*), - "required" -> Json.Array(recordFields.map(field => Json.String(field.name)): _*), + "type" -> Json.String("object"), + "properties" -> Json.Object(recordFields.map(field => field.name -> schemaJson(graph, field.body)): _*), + "required" -> Json.Array( + recordFields.filterNot(field => isOption(field.body)).map(field => Json.String(field.name)): _* + ), "additionalProperties" -> Json.Boolean(false) ) case VariantType(cases) => @@ -207,15 +225,25 @@ private object CanonicalJson { "required" -> Json.Array(Json.String("bytes")), "additionalProperties" -> Json.Boolean(false) ) - case PathType(_) => typed("string", "format" -> Json.String("file-path")) - case UrlType(_) => typed("string", "format" -> Json.String("uri")) - case DatetimeType => typed("string", "format" -> Json.String("date-time")) - case DurationType => typed("string", "format" -> Json.String("duration")) + case PathType(_) => typed("string", "format" -> Json.String("file-path")) + case UrlType(_) => typed("string", "format" -> Json.String("uri")) + case DatetimeType => typed("string", "format" -> Json.String("date-time")) + case DurationType => + typed( + "object", + "properties" -> Json.Object("nanoseconds" -> wideInteger(unsigned = false)), + "required" -> Json.Array(Json.String("nanoseconds")), + "additionalProperties" -> Json.Boolean(false), + "title" -> Json.String("Duration in nanoseconds") + ) case QuantityType(_) => typed( "object", - "properties" -> Json - .Object("mantissa" -> typed("integer"), "scale" -> typed("integer"), "unit" -> typed("string")), + "properties" -> Json.Object( + "mantissa" -> wideInteger(unsigned = false), + "scale" -> typed("integer"), + "unit" -> typed("string") + ), "required" -> Json.Array(Json.String("mantissa"), Json.String("scale"), Json.String("unit")), "additionalProperties" -> Json.Boolean(false) ) @@ -223,11 +251,8 @@ private object CanonicalJson { Json.Object("oneOf" -> Json.Array(branches.map { branch => Json.Object("allOf" -> Json.Array(schemaJson(graph, branch.body), discriminatorJson(branch.discriminator))) }: _*)) - case SecretType(_) => Json.Object("x-golem-capability" -> Json.String("secret")) - case QuotaTokenType(_) => Json.Object("x-golem-capability" -> Json.String("quota-token")) - case PermissionCardType(_) => Json.Object("x-golem-capability" -> Json.String("permission-card")) - case FutureType(_) => Json.Object("x-golem-unsupported" -> Json.String("future")) - case StreamType(_) => Json.Object("x-golem-unsupported" -> Json.String("stream")) + case SecretType(_) | QuotaTokenType(_) | PermissionCardType(_) | FutureType(_) | StreamType(_) => + Json.Object("not" -> Json.Object()) } } @@ -294,9 +319,14 @@ private object CanonicalJson { result } - private def safeLong(json: Json, unsigned: Boolean): Long = { - val min = if (unsigned) BigInt(0) else -MaxSafeInteger - val value = integral(json, min, MaxSafeInteger) + private def canonicalLong(json: Json, unsigned: Boolean): Long = { + val text = string(json) + val pattern = if (unsigned) UnsignedIntegerPattern else SignedIntegerPattern + if (!pattern.pattern.matcher(text).matches()) fail("expected a canonical decimal integer string") + val value = BigInt(text) + val min = if (unsigned) BigInt(0) else BigInt(Long.MinValue) + val max = if (unsigned) U64Max else BigInt(Long.MaxValue) + if (value < min || value > max) fail(s"integer is outside [$min, $max]") value.toLong } @@ -306,11 +336,11 @@ private object CanonicalJson { case S8Type(_) => S8Value(integral(json, -128, 127).toByte) case S16Type(_) => S16Value(integral(json, -32768, 32767).toShort) case S32Type(_) => S32Value(integral(json, Int.MinValue, Int.MaxValue).toInt) - case S64Type(_) => S64Value(safeLong(json, unsigned = false)) + case S64Type(_) => S64Value(canonicalLong(json, unsigned = false)) case U8Type(_) => U8Value(integral(json, 0, 255).toInt) case U16Type(_) => U16Value(integral(json, 0, 65535).toInt) case U32Type(_) => U32Value(integral(json, 0, BigInt("4294967295")).toLong) - case U64Type(_) => U64Value(safeLong(json, unsigned = true)) + case U64Type(_) => U64Value(canonicalLong(json, unsigned = true)) case F32Type(_) => val value = decimal(json).toFloat if (!java.lang.Float.isFinite(value)) fail("number is outside the f32 range") @@ -328,13 +358,17 @@ private object CanonicalJson { val jsonFields = fields(json) jsonFields.keys.find(name => !expected.exists(_.name == name)).foreach(name => fail(s"unknown field '$name'")) RecordValue( - expected.map(field => - packUnsafe( - graph, - resolve(graph, field.body), - jsonFields.getOrElse(field.name, fail(s"missing field '${field.name}'")) - ) - ) + expected.map { field => + val resolved = resolve(graph, field.body) + jsonFields.get(field.name) match { + case Some(value) => packUnsafe(graph, resolved, value) + case None => + resolved.body match { + case OptionType(_) => OptionValue(None) + case _ => fail(s"missing field '${field.name}'") + } + } + } ) case VariantType(cases) => json match { @@ -412,12 +446,15 @@ private object CanonicalJson { case DatetimeType => val instant = java.time.Instant.parse(string(json)) DatetimeValue(Datetime(instant.getEpochSecond, instant.getNano)) - case DurationType => DurationValue(decodeDuration(string(json))) + case DurationType => + val jsonFields = fields(json) + if (jsonFields.keySet != Set("nanoseconds")) fail("duration JSON requires only 'nanoseconds'") + DurationValue(canonicalLong(jsonFields("nanoseconds"), unsigned = false)) case QuantityType(_) => val jsonFields = fields(json) QuantityValueNode( QuantityValue( - safeLong(jsonFields("mantissa"), unsigned = false), + canonicalLong(jsonFields("mantissa"), unsigned = false), integral(jsonFields("scale"), Int.MinValue, Int.MaxValue).toInt, string(jsonFields("unit")) ) @@ -436,15 +473,16 @@ private object CanonicalJson { private def unpackUnsafe(graph: SchemaGraph, schema: SchemaType, value: SchemaValue): Json = (schema.body, value) match { - case (BoolType, BoolValue(x)) => Json.Boolean(x) - case (S8Type(_), S8Value(x)) => number(BigDecimal(x)) - case (S16Type(_), S16Value(x)) => number(BigDecimal(x)) - case (S32Type(_), S32Value(x)) => number(BigDecimal(x)) - case (S64Type(_), S64Value(x)) if BigInt(x).abs <= MaxSafeInteger => number(BigDecimal(x)) - case (U8Type(_), U8Value(x)) => number(BigDecimal(x)) - case (U16Type(_), U16Value(x)) => number(BigDecimal(x)) - case (U32Type(_), U32Value(x)) => number(BigDecimal(x)) - case (U64Type(_), U64Value(x)) if x >= 0 && BigInt(x) <= MaxSafeInteger => number(BigDecimal(x)) + case (BoolType, BoolValue(x)) => Json.Boolean(x) + case (S8Type(_), S8Value(x)) => number(BigDecimal(x)) + case (S16Type(_), S16Value(x)) => number(BigDecimal(x)) + case (S32Type(_), S32Value(x)) => number(BigDecimal(x)) + case (S64Type(_), S64Value(x)) => Json.String(x.toString) + case (U8Type(_), U8Value(x)) => number(BigDecimal(x)) + case (U16Type(_), U16Value(x)) => number(BigDecimal(x)) + case (U32Type(_), U32Value(x)) => number(BigDecimal(x)) + case (U64Type(_), U64Value(x)) => + Json.String(java.lang.Long.toUnsignedString(x)) case (F32Type(_), F32Value(x)) => number(BigDecimal.decimal(x)) case (F64Type(_), F64Value(x)) => number(BigDecimal(x)) case (CharType, CharValue(x)) => Json.String(new String(Character.toChars(x))) @@ -512,10 +550,11 @@ private object CanonicalJson { case (UrlType(_), UrlValue(x)) => Json.String(x) case (DatetimeType, DatetimeValue(x)) => Json.String(java.time.Instant.ofEpochSecond(x.seconds, x.nanoseconds.toLong).toString) - case (DurationType, DurationValue(x)) => Json.String(encodeDuration(x)) + case (DurationType, DurationValue(x)) => + Json.Object("nanoseconds" -> Json.String(x.toString)) case (QuantityType(_), QuantityValueNode(x)) => Json.Object( - "mantissa" -> number(BigDecimal(x.mantissa)), + "mantissa" -> Json.String(x.mantissa.toString), "scale" -> number(BigDecimal(x.scale)), "unit" -> Json.String(x.unit) ) @@ -600,47 +639,4 @@ private object CanonicalJson { result.result() } - private val DurationPattern = - "^(-)?P(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)(?:\\.(\\d{1,9}))?S)?)?$".r - - private def decodeDuration(value: String): Long = value match { - case DurationPattern(sign, days, hours, minutes, seconds, fraction) => - if (List(days, hours, minutes, seconds).forall(_ == null)) fail("expected an ISO 8601 duration") - def amount(raw: String): BigInt = if (raw == null) BigInt(0) else BigInt(raw) - val fractional = Option(fraction).fold(BigInt(0))(raw => BigInt(raw.padTo(9, '0').mkString)) - val nanos = amount(days) * 86400000000000L + amount(hours) * 3600000000000L + - amount(minutes) * 60000000000L + amount(seconds) * 1000000000L + fractional - val signed = if (sign == null) nanos else -nanos - if (!signed.isValidLong) fail("duration nanoseconds out of i64 range") - signed.toLong - case _ => fail("expected an ISO 8601 duration") - } - - private def encodeDuration(nanoseconds: Long): String = - if (nanoseconds == 0) "PT0S" - else { - val negative = nanoseconds < 0 - var remaining = BigInt(nanoseconds).abs - val days = remaining / 86400000000000L - remaining %= 86400000000000L - val hours = remaining / 3600000000000L - remaining %= 3600000000000L - val minutes = remaining / 60000000000L - remaining %= 60000000000L - val seconds = remaining / 1000000000L - val nanos = remaining % 1000000000L - val result = new StringBuilder(if (negative) "-P" else "P") - if (days != 0) result.append(days).append('D') - if (hours != 0 || minutes != 0 || seconds != 0 || nanos != 0) { - result.append('T') - if (hours != 0) result.append(hours).append('H') - if (minutes != 0) result.append(minutes).append('M') - if (seconds != 0 || nanos != 0) { - result.append(seconds) - if (nanos != 0) result.append('.').append(f"${nanos.toLong}%09d".reverse.dropWhile(_ == '0').reverse) - result.append('S') - } - } - result.result() - } } diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala index 4c717c28aa..0d1a73d3f0 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala @@ -171,18 +171,121 @@ object SchemaRefSpec extends ZIOSpecDefault { union.packJson(Json.String("http://host")).isLeft ) }, - test("requires explicit null for an absent option and renders it as required") { + test("decodes omitted and explicit-null option fields as absent") { val optional = SchemaRef( + SchemaGraph( + ListMap("maybe-ref" -> SchemaTypeDef(SchemaType(OptionType(SchemaType(StringType))))), + SchemaType( + RecordType( + List( + NamedFieldType("maybe", SchemaType(OptionType(SchemaType(StringType)))), + NamedFieldType("referenced", SchemaType(RefType("maybe-ref"))) + ) + ) + ) + ) + ) + val absent = RecordValue(List(OptionValue(None), OptionValue(None))) + assertTrue( + optional.packJson(Json.Object()) == Right(absent), + optional.packJson(Json.Object("maybe" -> Json.Null, "referenced" -> Json.Null)) == Right(absent), + optional.toJsonSchema().get("required").one == Right(Json.Array()) + ) + }, + test("uses lossless canonical JSON for wide integers, durations, and quantities") { + val wide = SchemaRef( SchemaGraph( ListMap.empty, - SchemaType(RecordType(List(NamedFieldType("maybe", SchemaType(OptionType(SchemaType(StringType))))))) + SchemaType( + RecordType( + List( + NamedFieldType("signed", SchemaType(S64Type())), + NamedFieldType("unsigned", SchemaType(U64Type())), + NamedFieldType("duration", SchemaType(DurationType)), + NamedFieldType( + "quantity", + SchemaType(QuantityType(QuantitySpec("m", Nil, None, None))) + ) + ) + ) + ) + ) + ) + val json = Json.Object( + "signed" -> Json.String(Long.MinValue.toString), + "unsigned" -> Json.String("18446744073709551615"), + "duration" -> Json.Object("nanoseconds" -> Json.String(Long.MaxValue.toString)), + "quantity" -> Json.Object( + "mantissa" -> Json.String(Long.MinValue.toString), + "scale" -> Json.Number(BigDecimal(-2)), + "unit" -> Json.String("m") + ) + ) + val value = RecordValue( + List( + S64Value(Long.MinValue), + U64Value(-1L), + DurationValue(Long.MaxValue), + QuantityValueNode(QuantityValue(Long.MinValue, -2, "m")) ) ) + val rendered = wide.toJsonSchema(includeDraftMarker = false) + val props = rendered.get("properties").one.toOption.get assertTrue( - optional.packJson(Json.Object()).isLeft, - optional.packJson(Json.Object("maybe" -> Json.Null)) == Right(RecordValue(List(OptionValue(None)))), - optional.toJsonSchema().get("required").one == Right(Json.Array(Json.String("maybe"))) + wide.packJson(json) == Right(value), + wide.unpackJson(value) == Right(json), + props.get("signed").one.flatMap(_.get("pattern").one) == + Right(Json.String("^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$")), + props.get("unsigned").one.flatMap(_.get("x-golem-maximum").one) == + Right(Json.String("18446744073709551615")), + props.get("duration").one.flatMap(_.get("type").one) == Right(Json.String("object")), + props + .get("quantity") + .one + .flatMap(_.get("properties").one) + .flatMap(_.get("mantissa").one) + .flatMap(_.get("type").one) == Right(Json.String("string")) + ) + }, + test("rejects non-canonical or overflowing wide decimal strings") { + val signed = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(S64Type()))) + val unsigned = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(U64Type()))) + val duration = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(DurationType))) + val quantity = SchemaRef( + SchemaGraph(ListMap.empty, SchemaType(QuantityType(QuantitySpec("m", Nil, None, None)))) + ) + assertTrue( + List(Json.String("+1"), Json.String("01"), Json.String("-0"), Json.String("9223372036854775808")) + .forall(signed.packJson(_).isLeft), + List(Json.String("-1"), Json.String("+1"), Json.String("01"), Json.String("18446744073709551616")) + .forall(unsigned.packJson(_).isLeft), + duration.packJson(Json.String("PT1S")).isLeft, + duration.packJson(Json.Object("nanoseconds" -> Json.Number(BigDecimal(1)))).isLeft, + quantity + .packJson( + Json.Object( + "mantissa" -> Json.String("-0"), + "scale" -> Json.Number(BigDecimal(0)), + "unit" -> Json.String("m") + ) + ) + .isLeft + ) + }, + test("reflection JSON Schema rejects leaves with no JSON representation") { + val leaves = List[SchemaTypeBody]( + SecretType(SecretSpec(SchemaType(StringType), None)), + QuotaTokenType(QuotaTokenSpec(None)), + PermissionCardType(PermissionCardSpec(polymorphic = false)), + FutureType(None), + StreamType(None) ) + assertTrue(leaves.forall { body => + SchemaRef(SchemaGraph(ListMap.empty, SchemaType(body))) + .toJsonSchema(includeDraftMarker = false) + .get("not") + .one == Right(Json.Object()) + }) }, test("rejects numbers that overflow after float narrowing") { val f32 = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(F32Type()))) diff --git a/sdks/scala/integration-tests/src/test/scala/golem/integration/GolemExamplesIntegrationSpec.scala b/sdks/scala/integration-tests/src/test/scala/golem/integration/GolemExamplesIntegrationSpec.scala index 9a8ead4fb8..f730436002 100644 --- a/sdks/scala/integration-tests/src/test/scala/golem/integration/GolemExamplesIntegrationSpec.scala +++ b/sdks/scala/integration-tests/src/test/scala/golem/integration/GolemExamplesIntegrationSpec.scala @@ -309,7 +309,7 @@ object GolemServer { file } process <- ZIO.acquireRelease( - Cmd("golem", "-vvv", "server", "run", "--clean", "--disable-app-manifest-discovery") + Cmd("golem", "--yes", "-vvv", "server", "run", "--clean", "--disable-app-manifest-discovery") .workingDirectory(examplesDir) .env(buildEnv) .redirectErrorStream(true) @@ -624,6 +624,11 @@ object GolemExamplesIntegrationSpec extends ZIOSpec[GolemServer] { "samples/principal/principal.ts", Contains("was created by:") ), + Sample( + "reflection-canonical-json", + "samples/reflection/repl-reflection-canonical.ts", + Contains("scala-canonical-ok", "omitted", "supplied") + ), // --- Database (requires external DB) --- Sample( diff --git a/sdks/scala/model/src/main/scala/golem/schema/Derivation.scala b/sdks/scala/model/src/main/scala/golem/schema/Derivation.scala index 94faa7c015..8b510ed984 100644 --- a/sdks/scala/model/src/main/scala/golem/schema/Derivation.scala +++ b/sdks/scala/model/src/main/scala/golem/schema/Derivation.scala @@ -79,20 +79,41 @@ private[golem] object Derivation { /** Convert a Scala value into its structural [[SchemaValue]]. */ def toValue[A](schema: Schema[A], value: A): SchemaValue = - try dynamicToSchemaValue(schema.reflect, schema.toDynamicValue(value)) - catch { + try { + val reflect = schema.reflect + if (isInstant(reflect)) { + val instant = value.asInstanceOf[Instant] + SchemaValue.DatetimeValue(Datetime(instant.getEpochSecond, instant.getNano)) + } else if (isDuration(reflect)) { + SchemaValue.DurationValue(value.asInstanceOf[JDuration].toNanos) + } else dynamicToSchemaValue(reflect, schema.toDynamicValue(value)) + } catch { case e: SchemaEncodeError => throw e case NonFatal(e) => throw SchemaEncodeError(Option(e.getMessage).getOrElse(e.toString)) } /** Reconstruct a Scala value from a structural [[SchemaValue]]. */ def fromValue[A](schema: Schema[A], value: SchemaValue): Either[FromSchemaError, A] = - try - schema - .fromDynamicValue(schemaValueToDynamic(schema.reflect, value)) - .left - .map(err => FromSchemaError(err.toString)) - catch { + try { + val reflect = schema.reflect + if (isInstant(reflect)) + value match { + case SchemaValue.DatetimeValue(v) => + validateNanoseconds(v.nanoseconds) + Right(Instant.ofEpochSecond(v.seconds, v.nanoseconds.toLong).asInstanceOf[A]) + case other => Left(FromSchemaError(s"expected datetime value for Instant, got $other")) + } + else if (isDuration(reflect)) + value match { + case SchemaValue.DurationValue(v) => Right(JDuration.ofNanos(v).asInstanceOf[A]) + case other => Left(FromSchemaError(s"expected duration value for Duration, got $other")) + } + else + schema + .fromDynamicValue(schemaValueToDynamic(reflect, value)) + .left + .map(err => FromSchemaError(err.toString)) + } catch { case e: FromSchemaError => Left(e) case NonFatal(e) => Left(FromSchemaError(Option(e.getMessage).getOrElse(e.toString))) } diff --git a/sdks/scala/model/src/test/scala/golem/schema/RichTypesSpec.scala b/sdks/scala/model/src/test/scala/golem/schema/RichTypesSpec.scala index 54bee3d89b..9da152d0e0 100644 --- a/sdks/scala/model/src/test/scala/golem/schema/RichTypesSpec.scala +++ b/sdks/scala/model/src/test/scala/golem/schema/RichTypesSpec.scala @@ -87,6 +87,20 @@ object RichTypesSpec extends ZIOSpecDefault { FromSchema[Quantity[Bytes]].fromValue(IntoSchema[Quantity[Bytes]].toValue(quantity)) == Right(quantity) ) }, + test("native Java time schemas use rich structural values") { + val duration = JDuration.ofNanos(Long.MaxValue) + val instant = Instant.ofEpochSecond(1234L, 567) + val durationInto = IntoSchema.derived[JDuration](using Schema.duration) + val durationFrom = FromSchema.derived[JDuration](using Schema.duration) + val instantInto = IntoSchema.derived[Instant](using Schema.instant) + val instantFrom = FromSchema.derived[Instant](using Schema.instant) + assertTrue( + durationInto.toValue(duration) == SchemaValue.DurationValue(Long.MaxValue), + durationFrom.fromValue(SchemaValue.DurationValue(Long.MaxValue)) == Right(duration), + instantInto.toValue(instant) == SchemaValue.DatetimeValue(Datetime(1234L, 567)), + instantFrom.fromValue(SchemaValue.DatetimeValue(Datetime(1234L, 567))) == Right(instant) + ) + }, test("derived record fields use rich nodes and roundtrip") { val record = RichRecord( GolemPath("/workspace/data"), diff --git a/sdks/scala/test-agents/samples/reflection/repl-reflection-canonical.ts b/sdks/scala/test-agents/samples/reflection/repl-reflection-canonical.ts new file mode 100644 index 0000000000..f5d72639f8 --- /dev/null +++ b/sdks/scala/test-agents/samples/reflection/repl-reflection-canonical.ts @@ -0,0 +1,3 @@ +const caller = await ScalaToolReflectionCaller.get("canonical-json"); +console.log(await caller.canonicalRoundTrip()); +console.log(await caller.optionalRoundTrip()); diff --git a/sdks/scala/test-agents/src/main/scala-3/example/integrationtests/ToolReflectionCaller.scala b/sdks/scala/test-agents/src/main/scala-3/example/integrationtests/ToolReflectionCaller.scala index 461ddd584e..082ac1812b 100644 --- a/sdks/scala/test-agents/src/main/scala-3/example/integrationtests/ToolReflectionCaller.scala +++ b/sdks/scala/test-agents/src/main/scala-3/example/integrationtests/ToolReflectionCaller.scala @@ -10,26 +10,61 @@ package example.integrationtests -import golem.{BaseAgent, Principal} +import golem.{BaseAgent, Principal, ULong} import golem.reflection.{AgentClientDefinition, DynamicAgentClient, DynamicToolClient, GolemReflectError, Reflection} import golem.runtime.annotations.* import golem.runtime.{InputRecordCodec, OutputCodec} -import golem.schema.{SchemaValue, TypedSchemaValue} +import golem.schema.{Quantity, QuantityUnit, SchemaValue, TypedSchemaValue} import zio.blocks.schema.json.Json +import zio.blocks.typeid.TypeId +import java.time.{Duration => JDuration} import scala.concurrent.{ExecutionContext, Future} +sealed trait ReflectionMeters + +object ReflectionMeters { + implicit val unit: QuantityUnit[ReflectionMeters] = new QuantityUnit[ReflectionMeters] { + override val baseUnit: String = "m" + override val allowedSuffixes: List[String] = Nil + override val typeId: TypeId[ReflectionMeters] = TypeId.of[ReflectionMeters] + } +} + @toolDefinition(name = "scala-reflection-test") trait ScalaReflectionTestTool { def echo(label: String): String @arg("maybe", scope = "option") def optional(maybe: Option[String]): String + @arg("maybe", scope = "option") + def canonicalValues( + signed: Long, + unsigned: ULong, + duration: JDuration, + quantity: Quantity[ReflectionMeters], + maybe: Option[String] + ): String } @toolImplementation() final class ScalaReflectionTestToolImpl extends ScalaReflectionTestTool { override def echo(label: String): String = s"scala-tool:$label" override def optional(maybe: Option[String]): String = maybe.getOrElse("omitted") + override def canonicalValues( + signed: Long, + unsigned: ULong, + duration: JDuration, + quantity: Quantity[ReflectionMeters], + maybe: Option[String] + ): String = + if ( + signed == Long.MinValue && + unsigned.value == ((BigInt(1) << 64) - 1) && + duration.toNanos == Long.MaxValue && + quantity == Quantity[ReflectionMeters](Long.MinValue, -9, "m") && + maybe.isEmpty + ) "scala-canonical-ok" + else "scala-canonical-mismatch" } @agentDefinition() @@ -48,6 +83,7 @@ trait ScalaToolReflectionCaller extends BaseAgent { class Id(val name: String) def roundTrip(): Future[String] def optionalRoundTrip(): Future[String] + def canonicalRoundTrip(): Future[String] def principalRoundTrip(): Future[String] def agentRoundTrip(): Future[String] } @@ -66,14 +102,41 @@ final class ScalaToolReflectionCallerImpl(name: String) extends ScalaToolReflect case Left(error) => Future.successful(s"error:$error") case Right(command) => for { - omittedJson <- command.invokeJson(Json.Object("maybe" -> Json.Null)) + omittedJson <- command.invokeJson(Json.Object()) + nullJson <- command.invokeJson(Json.Object("maybe" -> Json.Null)) suppliedJson <- command.invokeJson(Json.Object("maybe" -> Json.String("supplied"))) omittedNative <- command.invokeValue(SchemaValue.RecordValue(List(SchemaValue.OptionValue(None)))) suppliedNative <- command.invokeValue( SchemaValue.RecordValue(List(SchemaValue.OptionValue(Some(SchemaValue.StringValue("supplied"))))) ) - } yield s"$omittedJson|$suppliedJson|$omittedNative|$suppliedNative" + } yield s"$omittedJson|$nullJson|$suppliedJson|$omittedNative|$suppliedNative" + } + } + + override def canonicalRoundTrip(): Future[String] = { + val prepared = for { + tool <- Reflection.getToolType("scala-reflection-test").left.map(_.toString) + command <- tool.command(List("canonical-values")).left.map(_.toString) + } yield command + + prepared match { + case Left(error) => Future.successful(s"error:$error") + case Right(command) => + command + .invokeJson( + Json.Object( + "signed" -> Json.String(Long.MinValue.toString), + "unsigned" -> Json.String("18446744073709551615"), + "duration" -> Json.Object("nanoseconds" -> Json.String(Long.MaxValue.toString)), + "quantity" -> Json.Object( + "mantissa" -> Json.String(Long.MinValue.toString), + "scale" -> Json.Number(BigDecimal(-9)), + "unit" -> Json.String("m") + ) + ) + ) + .map(_.toString) } } From 31578a42bc684934382bdc77ecada745bd1a705f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 14:43:01 +0200 Subject: [PATCH 11/37] moonbit: use lossless canonical JSON for wide values --- cli/golem-cli/tests/app/mod.rs | 2 + cli/golem-cli/tests/app/moonbit_reflection.rs | 162 ++++++++ .../golem_sdk/reflection/json_schema.mbt | 125 +++--- sdks/moonbit/golem_sdk/reflection/moon.pkg | 1 + .../golem_sdk/reflection/rich_json.mbt | 178 --------- .../golem_sdk/reflection/schema_ref.mbt | 80 ++-- .../reflection/schema_ref_wbtest.mbt | 147 +++++-- .../golem_tool_clients.mbt | 365 +++++++++++------- .../golem_moonbit_examples/golem_tools.mbt | 226 ++++++++++- .../tool_reflection.mbt | 81 +++- 10 files changed, 933 insertions(+), 434 deletions(-) create mode 100644 cli/golem-cli/tests/app/moonbit_reflection.rs diff --git a/cli/golem-cli/tests/app/mod.rs b/cli/golem-cli/tests/app/mod.rs index 80f7767e3d..d833c67e17 100644 --- a/cli/golem-cli/tests/app/mod.rs +++ b/cli/golem-cli/tests/app/mod.rs @@ -23,6 +23,7 @@ mod cards; mod directory_source_ifs; mod external_durable_streams; mod moonbit_guest_streams; +mod moonbit_reflection; mod moonbit_tool_middleware; mod plugins; mod remote_releases; @@ -49,6 +50,7 @@ tag_suite!(cards, deploy); tag_suite!(directory_source_ifs, deploy); tag_suite!(external_durable_streams, agents_streaming); tag_suite!(moonbit_guest_streams, agents_guest_bridge); +tag_suite!(moonbit_reflection, deploy); tag_suite!(moonbit_tool_middleware, deploy); tag_suite!(plugins, deploy); tag_suite!(rust_streams, agents_guest_bridge); diff --git a/cli/golem-cli/tests/app/moonbit_reflection.rs b/cli/golem-cli/tests/app/moonbit_reflection.rs new file mode 100644 index 0000000000..6c8cfeb572 --- /dev/null +++ b/cli/golem-cli/tests/app/moonbit_reflection.rs @@ -0,0 +1,162 @@ +use crate::Tracing; +use crate::app::{TestContext, cmd, flag}; +use golem_cli::{fs, versions}; +use indoc::{formatdoc, indoc}; +use test_r::{inherit_test_dep, test, timeout}; +use uuid::Uuid; + +inherit_test_dep!(Tracing); + +async fn deployed_moonbit_reflection_context() -> TestContext { + let mut ctx = TestContext::new(); + ctx.start_server().await; + fs::create_dir_all(ctx.cwd_path_join("moonbit-reflection")).unwrap(); + ctx.cd("moonbit-reflection"); + let created = ctx + .cli([flag::YES, cmd::NEW, ".", flag::TEMPLATE, "moonbit"]) + .await; + assert!(created.success_or_dump()); + + fs::write_str( + ctx.cwd_path_join("golem.yaml"), + formatdoc! {r#" + manifestVersion: {version} + app: moonbit-reflection + environments: + local: + server: local + componentPresets: debug + components: + moonbit-reflection:main: + dir: . + templates: moonbit + tools: + reflection-conformance: {{}} + agents: + ReflectionCaller: + tools: + reflection-conformance: {{}} + "#, version = versions::sdk::MANIFEST}, + ) + .unwrap(); + + let package_path = ctx.cwd_path_join("moon.pkg"); + let package = fs::read_to_string(&package_path).unwrap().replace( + "import {", + "import {\n \"golemcloud/golem_sdk/reflection\",", + ); + fs::write_str(package_path, package).unwrap(); + fs::write_str( + ctx.cwd_path_join("reflection.mbt"), + indoc! {r#" + #derive.tool("reflection-conformance") + struct ReflectionConformanceTool {} + + #warnings("-struct_never_constructed") + struct Meters {} + + impl @schema.QuantityUnit for Meters with fn type_id() { + "reflection-conformance.Meters" + } + + impl @schema.QuantityUnit for Meters with fn base_unit() { "m" } + + impl @schema.QuantityUnit for Meters with fn allowed_suffixes() { [] } + + #derive.arg("maybe", scope="option") + pub fn ReflectionConformanceTool::canonical( + signed : Int64, + unsigned : UInt64, + duration : @schema.Duration, + quantity : @schema.Quantity[Meters], + maybe : String?, + ) -> String { + let quantity = quantity.value() + if signed == -9223372036854775807L - 1L && + unsigned == 18446744073709551615UL && + duration == @schema.Duration::new(9223372036854775807L) && + quantity.mantissa == -9223372036854775807L - 1L && + quantity.scale == -9 && + quantity.unit == "m" && + maybe is None { + "moonbit-canonical-ok" + } else { + "moonbit-canonical-mismatch" + } + } + + #derive.agent + struct ReflectionCaller { name : String } + + fn ReflectionCaller::new(name : String) -> ReflectionCaller { { name, } } + + fn describe_tool_error( + error : @tool.ToolError[@reflection.ReflectedToolCustomError], + ) -> String { + match error { + Rpc(error) => "rpc:\{Repr(error)}" + RemoteTool(error) => "remote:\{Repr(error)}" + Tool(custom) => { + @model.drop_owned_capabilities(custom.payload.value) + "tool" + } + UnknownToolError(_, payload) => { + @model.drop_owned_capabilities(payload.value) + "unknown" + } + InvalidInput(message) => "input:\{message}" + MalformedRemoteOutput(message) => "output:\{message}" + } + } + + pub async fn ReflectionCaller::run(self : Self) -> String { + ignore(self) + let tool = @reflection.get_tool_type("reflection-conformance") catch { + error => return "discovery:\{Repr(error)}" + } + let command = tool.command(["canonical"]) catch { + error => return "command:\{Repr(error)}" + } + let canonical = command.invoke_json(Json::object({ + "signed": Json::string("-9223372036854775808"), + "unsigned": Json::string("18446744073709551615"), + "duration": Json::object({ + "nanoseconds": Json::string("9223372036854775807"), + }), + "quantity": Json::object({ + "mantissa": Json::string("-9223372036854775808"), + "scale": Json::number(-9.0), + "unit": Json::string("m"), + }), + })) + match canonical { + Ok(Some(Json::String(value))) => value + Ok(_) => "canonical-error:unexpected" + Err(error) => "canonical-error:\{describe_tool_error(error)}" + } + } + "#}, + ) + .unwrap(); + + assert!(ctx.cli([cmd::BUILD]).await.success_or_dump()); + assert!(ctx.cli([cmd::DEPLOY, flag::YES]).await.success_or_dump()); + ctx +} + +#[test] +#[timeout("20 minutes")] +async fn test_moonbit_reflected_invoke_json_preserves_canonical_wide_values() { + let ctx = deployed_moonbit_reflection_context().await; + let output = ctx + .cli([ + flag::YES, + cmd::AGENT, + cmd::INVOKE, + &format!("ReflectionCaller(\"{}\")", Uuid::new_v4()), + "run", + ]) + .await; + assert!(output.success_or_dump()); + assert!(output.stdout_contains("moonbit-canonical-ok")); +} diff --git a/sdks/moonbit/golem_sdk/reflection/json_schema.mbt b/sdks/moonbit/golem_sdk/reflection/json_schema.mbt index e2b975adda..1e751d6678 100644 --- a/sdks/moonbit/golem_sdk/reflection/json_schema.mbt +++ b/sdks/moonbit/golem_sdk/reflection/json_schema.mbt @@ -5,7 +5,7 @@ pub fn SchemaRef::to_json_schema( self : SchemaRef, include_draft_marker? : Bool = true, ) -> Json { - let root = schema_json(self.root) + let root = schema_json(self.graph, self.root) let fields : Map[String, Json] = match root { Object(value) => value _ => {} @@ -18,7 +18,7 @@ pub fn SchemaRef::to_json_schema( if !self.graph.defs.is_empty() { let defs : Map[String, Json] = Map([]) for definition in self.graph.defs { - defs[definition.id] = schema_json(definition.body) + defs[definition.id] = schema_json(self.graph, definition.body) } fields["$defs"] = Json::object(defs) } @@ -127,10 +127,15 @@ fn schema_wide_integer( restrictions : @types.NumericRestrictions?, ) -> Json { let fields : Map[String, Json] = { - "type": Json::string("integer"), - "minimum": Json::number(if signed { -9007199254740991.0 } else { 0.0 }), - "maximum": Json::number(9007199254740991.0), - "x-golem-numeric-width": Json::string(if signed { "s64" } else { "u64" }), + "type": Json::string("string"), + "format": Json::string(if signed { "int64" } else { "uint64" }), + "pattern": Json::string( + if signed { + "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$" + } else { + "^(?:0|[1-9][0-9]*)$" + }, + ), "x-golem-minimum": Json::string( if signed { "-9223372036854775808" @@ -149,23 +154,13 @@ fn schema_wide_integer( match restrictions { Some(value) => { match value.min { - Some(bound) => { - fields["minimum"] = Json::number( - (if signed { -9007199254740991.0 } else { 0.0 }).max( - schema_bound_number(bound), - ), - ) + Some(bound) => fields["x-golem-minimum"] = Json::string(schema_bound_text(bound)) - } None => () } match value.max { - Some(bound) => { - fields["maximum"] = Json::number( - 9007199254740991.0.min(schema_bound_number(bound)), - ) + Some(bound) => fields["x-golem-maximum"] = Json::string(schema_bound_text(bound)) - } None => () } } @@ -175,15 +170,43 @@ fn schema_wide_integer( } ///| -fn schema_record(fields : Array[@model.NamedFieldType]) -> Json { +fn schema_resolves_to_option( + graph : @model.SchemaGraph, + schema : @model.SchemaType, + visited : Map[String, Unit], +) -> Bool { + match schema.body { + Option(_) => true + Ref(id) => + if visited.contains(id) { + false + } else { + visited[id] = () + match graph.defs.iter().find_first(definition => definition.id == id) { + Some(definition) => + schema_resolves_to_option(graph, definition.body, visited) + None => false + } + } + _ => false + } +} + +///| +fn schema_record( + graph : @model.SchemaGraph, + fields : Array[@model.NamedFieldType], +) -> Json { let properties : Map[String, Json] = Map([]) let required : Array[Json] = [] for field in fields { properties[field.name] = schema_with_metadata( - schema_json(field.body), + schema_json(graph, field.body), field.metadata, ) - required.push(Json::string(field.name)) + if !schema_resolves_to_option(graph, field.body, Map([])) { + required.push(Json::string(field.name)) + } } Json::object({ "type": Json::string("object"), @@ -288,14 +311,17 @@ fn schema_quantity_bound(value : @types.QuantityValue) -> Json { } ///| -fn schema_variant(cases : Array[@model.VariantCaseType]) -> Json { +fn schema_variant( + graph : @model.SchemaGraph, + cases : Array[@model.VariantCaseType], +) -> Json { let choices : Array[Json] = [] for entry in cases { match entry.payload { None => choices.push(Json::object({ "const": Json::string(entry.name) })) Some(payload) => { let properties : Map[String, Json] = Map([]) - properties[entry.name] = schema_json(payload) + properties[entry.name] = schema_json(graph, payload) choices.push( Json::object({ "type": Json::string("object"), @@ -322,10 +348,14 @@ fn schema_pair(first : Json, second : Json) -> Json { } ///| -fn schema_result_side(name : String, payload : @model.SchemaType?) -> Json { +fn schema_result_side( + graph : @model.SchemaGraph, + name : String, + payload : @model.SchemaType?, +) -> Json { let properties : Map[String, Json] = Map([]) properties[name] = match payload { - Some(value) => schema_json(value) + Some(value) => schema_json(graph, value) None => schema_type("null") } Json::object({ @@ -337,7 +367,7 @@ fn schema_result_side(name : String, payload : @model.SchemaType?) -> Json { } ///| -fn schema_json(schema : @model.SchemaType) -> Json { +fn schema_json(graph : @model.SchemaGraph, schema : @model.SchemaType) -> Json { let rendered = match schema.body { Ref(id) => Json::object({ @@ -363,8 +393,8 @@ fn schema_json(schema : @model.SchemaType) -> Json { "maxLength": schema_number(1), }) String => schema_type("string") - Record(fields) => schema_record(fields) - Variant(cases) => schema_variant(cases) + Record(fields) => schema_record(graph, fields) + Variant(cases) => schema_variant(graph, cases) Enum(cases) => Json::object({ "type": Json::string("string"), @@ -382,7 +412,7 @@ fn schema_json(schema : @model.SchemaType) -> Json { Tuple(elements) => { let items : Array[Json] = [] for element in elements { - items.push(schema_json(element)) + items.push(schema_json(graph, element)) } Json::object({ "type": Json::string("array"), @@ -395,29 +425,29 @@ fn schema_json(schema : @model.SchemaType) -> Json { List(element) => Json::object({ "type": Json::string("array"), - "items": schema_json(element), + "items": schema_json(graph, element), }) FixedList(element, length) => Json::object({ "type": Json::string("array"), - "items": schema_json(element), + "items": schema_json(graph, element), "minItems": Json::number(length.to_double()), "maxItems": Json::number(length.to_double()), }) Map(key, value) => Json::object({ "type": Json::string("array"), - "items": schema_pair(schema_json(key), schema_json(value)), + "items": schema_pair(schema_json(graph, key), schema_json(graph, value)), }) Option(element) => Json::object({ - "oneOf": schema_array([schema_type("null"), schema_json(element)]), + "oneOf": schema_array([schema_type("null"), schema_json(graph, element)]), }) Result(ok, err) => Json::object({ "oneOf": schema_array([ - schema_result_side("ok", ok), - schema_result_side("err", err), + schema_result_side(graph, "ok", ok), + schema_result_side(graph, "err", err), ]), }) Text(restrictions) => { @@ -543,14 +573,19 @@ fn schema_json(schema : @model.SchemaType) -> Json { }) Duration => Json::object({ - "type": Json::string("string"), - "format": Json::string("duration"), + "type": Json::string("object"), + "properties": Json::object({ + "nanoseconds": schema_wide_integer(true, None), + }), + "required": schema_strings(["nanoseconds"]), + "additionalProperties": Json::boolean(false), + "title": Json::string("Duration in nanoseconds"), }) Quantity(spec) => { let fields : Map[String, Json] = { "type": Json::string("object"), "properties": Json::object({ - "mantissa": schema_type("integer"), + "mantissa": schema_wide_integer(true, None), "scale": schema_type("integer"), "unit": schema_type("string"), }), @@ -581,7 +616,10 @@ fn schema_json(schema : @model.SchemaType) -> Json { choices.push( Json::object({ "allOf": schema_array([ - schema_with_metadata(schema_json(branch.body), branch.metadata), + schema_with_metadata( + schema_json(graph, branch.body), + branch.metadata, + ), schema_discriminator(branch.discriminator), ]), }), @@ -589,13 +627,8 @@ fn schema_json(schema : @model.SchemaType) -> Json { } Json::object({ "oneOf": Json::array(choices) }) } - Secret(_) => Json::object({ "x-golem-capability": Json::string("secret") }) - QuotaToken(_) => - Json::object({ "x-golem-capability": Json::string("quota-token") }) - PermissionCard(_) => - Json::object({ "x-golem-capability": Json::string("permission-card") }) - Future(_) => Json::object({ "x-golem-unsupported": Json::string("future") }) - Stream(_) => Json::object({ "x-golem-unsupported": Json::string("stream") }) + Secret(_) | QuotaToken(_) | PermissionCard(_) | Future(_) | Stream(_) => + Json::object({ "not": Json::object({}) }) } schema_with_metadata(rendered, schema.metadata) } diff --git a/sdks/moonbit/golem_sdk/reflection/moon.pkg b/sdks/moonbit/golem_sdk/reflection/moon.pkg index 3fd31f3640..1e89bf5fb0 100644 --- a/sdks/moonbit/golem_sdk/reflection/moon.pkg +++ b/sdks/moonbit/golem_sdk/reflection/moon.pkg @@ -16,6 +16,7 @@ import { "moonbitlang/core/string", "moonbitlang/core/math", "moonbitlang/core/encoding/base64", + "moonbitlang/core/json", } supported_targets = "+wasm" diff --git a/sdks/moonbit/golem_sdk/reflection/rich_json.mbt b/sdks/moonbit/golem_sdk/reflection/rich_json.mbt index f8c356e96a..24c5ec6abe 100644 --- a/sdks/moonbit/golem_sdk/reflection/rich_json.mbt +++ b/sdks/moonbit/golem_sdk/reflection/rich_json.mbt @@ -1,181 +1,3 @@ -///| -fn json_uint64(value : StringView) -> UInt64 raise ReflectError { - @string.parse_uint64(value, base=10) catch { - _ => raise Json("invalid duration integer") - } -} - -///| -fn duration_to_json(value : Int64) -> String { - if value == 0L { - return "PT0S" - } - let negative = value < 0L - let mut remaining = if negative { - (-(value + 1L)).reinterpret_as_uint64() + 1UL - } else { - value.reinterpret_as_uint64() - } - let days = remaining / 86400000000000UL - remaining = remaining % 86400000000000UL - let hours = remaining / 3600000000000UL - remaining = remaining % 3600000000000UL - let minutes = remaining / 60000000000UL - remaining = remaining % 60000000000UL - let seconds = remaining / 1000000000UL - let nanos = remaining % 1000000000UL - let mut result = if negative { "-P" } else { "P" } - if days != 0UL { - result += "\{days}D" - } - if hours != 0UL || minutes != 0UL || seconds != 0UL || nanos != 0UL { - result += "T" - if hours != 0UL { - result += "\{hours}H" - } - if minutes != 0UL { - result += "\{minutes}M" - } - if seconds != 0UL || nanos != 0UL { - result += "\{seconds}" - if nanos != 0UL { - let mut fraction = "\{nanos}".pad_start(9, '0') - while fraction.has_suffix("0") { - fraction = fraction.view()[0:fraction.length() - 1].to_owned() - } - result += "." + fraction - } - result += "S" - } - } - result -} - -///| -fn duration_part( - part : StringView?, - factor : UInt64, - maximum : UInt64, -) -> UInt64 raise ReflectError { - match part { - None => 0UL - Some(value) => { - let number = json_uint64(value) - guard number <= maximum / factor else { - raise Json("duration nanoseconds out of i64 range") - } - number * factor - } - } -} - -///| -fn duration_add( - total : UInt64, - part : UInt64, - maximum : UInt64, -) -> UInt64 raise ReflectError { - guard part <= maximum - total else { - raise Json("duration nanoseconds out of i64 range") - } - total + part -} - -///| -fn duration_from_json(json : Json) -> Int64 raise ReflectError { - match json { - Object(fields) => { - guard fields.length() == 1 else { - raise Json("unexpected duration JSON field") - } - json_integer( - required_field(fields, "nanoseconds"), - -9007199254740991.0, - 9007199254740991.0, - ).to_int64() - } - String(text) => { - let shorthand = re"^(-?[0-9]+)(ns|us|ms|s)$".execute(text) - match shorthand { - Some(m) => { - let number = @string.parse_int64(m.group(1).unwrap(), base=10) catch { - _ => raise Json("duration nanoseconds out of i64 range") - } - let factor = match m.group(2).unwrap().to_owned() { - "ns" => 1L - "us" => 1000L - "ms" => 1000000L - _ => 1000000000L - } - if factor == 1L { - return number - } - guard number >= -9223372036854775807L / factor && - number <= 9223372036854775807L / factor else { - raise Json("duration nanoseconds out of i64 range") - } - number * factor - } - None => { - let iso = re"^(-)?P(?:([0-9]+)D)?(?:T(?:([0-9]+)H)?(?:([0-9]+)M)?(?:([0-9]+)(?:\.([0-9]{1,9}))?S)?)?$".execute( - text, - ) - guard iso is Some(m) else { - raise Json("expected an ISO 8601 duration") - } - guard m.group(2) is Some(_) || - m.group(3) is Some(_) || - m.group(4) is Some(_) || - m.group(5) is Some(_) else { - raise Json("expected an ISO 8601 duration") - } - let negative = m.group(1) is Some(_) - let maximum = if negative { - 9223372036854775808UL - } else { - 9223372036854775807UL - } - let mut total = duration_part(m.group(2), 86400000000000UL, maximum) - total = duration_add( - total, - duration_part(m.group(3), 3600000000000UL, maximum), - maximum, - ) - total = duration_add( - total, - duration_part(m.group(4), 60000000000UL, maximum), - maximum, - ) - total = duration_add( - total, - duration_part(m.group(5), 1000000000UL, maximum), - maximum, - ) - match m.group(6) { - Some(fraction) => { - let nanos = json_uint64( - fraction.to_owned().pad_end(9, '0').view(), - ) - total = duration_add(total, nanos, maximum) - } - None => () - } - if negative { - if total == 9223372036854775808UL { - -9223372036854775807L - 1L - } else { - -total.reinterpret_as_int64() - } - } else { - total.reinterpret_as_int64() - } - } - } - } - _ => raise Json("expected an ISO 8601 duration") - } -} - ///| fn days_from_civil(year : Int, month : Int, day : Int) -> Int { let y = if month <= 2 { year - 1 } else { year } diff --git a/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt b/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt index 01cf2f203b..9f69435047 100644 --- a/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt +++ b/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt @@ -615,6 +615,28 @@ fn json_integer( value } +///| +fn json_int64_string(value : Json) -> Int64 raise ReflectError { + let text = json_string(value) + guard re"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$".execute(text) is Some(_) else { + raise Json("expected a canonical signed integer string") + } + @string.parse_int64(text.view(), base=10) catch { + _ => raise Json("signed integer is outside the s64 range") + } +} + +///| +fn json_uint64_string(value : Json) -> UInt64 raise ReflectError { + let text = json_string(value) + guard re"^(?:0|[1-9][0-9]*)$".execute(text) is Some(_) else { + raise Json("expected a canonical unsigned integer string") + } + @string.parse_uint64(text.view(), base=10) catch { + _ => raise Json("unsigned integer is outside the u64 range") + } +} + ///| fn required_field( fields : Map[String, Json], @@ -760,24 +782,16 @@ fn SchemaRef::unpack( | (@model.SchemaTypeBody::S16(_), @model.SchemaValue::S16(value)) | (@model.SchemaTypeBody::S32(_), @model.SchemaValue::S32(value)) => Json::number(value.to_double()) - (@model.SchemaTypeBody::S64(_), @model.SchemaValue::S64(value)) => { - guard value >= -9007199254740991L && value <= 9007199254740991L else { - raise Json("64-bit integer is outside the safe JSON range") - } - Json::number(value.to_double()) - } + (@model.SchemaTypeBody::S64(_), @model.SchemaValue::S64(value)) => + Json::string(value.to_string()) (@model.SchemaTypeBody::U8(_), @model.SchemaValue::U8(value)) => Json::number(value.to_int().to_double()) (@model.SchemaTypeBody::U16(_), @model.SchemaValue::U16(value)) => Json::number(value.reinterpret_as_int().to_double()) (@model.SchemaTypeBody::U32(_), @model.SchemaValue::U32(value)) => Json::number(value.to_double()) - (@model.SchemaTypeBody::U64(_), @model.SchemaValue::U64(value)) => { - guard value <= 9007199254740991UL else { - raise Json("64-bit integer is outside the safe JSON range") - } - Json::number(value.to_double()) - } + (@model.SchemaTypeBody::U64(_), @model.SchemaValue::U64(value)) => + Json::string(value.to_string()) (@model.SchemaTypeBody::F32(_), @model.SchemaValue::F32(value)) => { guard !value.is_nan() && !value.is_inf() else { raise Json("expected a finite JSON number") @@ -938,21 +952,16 @@ fn SchemaRef::unpack( } Json::object(fields) } - (@model.SchemaTypeBody::Quantity(_), @model.SchemaValue::Quantity(value)) => { - guard value.mantissa >= -9007199254740991L && - value.mantissa <= 9007199254740991L else { - raise Json("quantity mantissa is outside the safe JSON range") - } + (@model.SchemaTypeBody::Quantity(_), @model.SchemaValue::Quantity(value)) => Json::object({ - "mantissa": Json::number(value.mantissa.to_double()), + "mantissa": Json::string(value.mantissa.to_string()), "scale": Json::number(value.scale.to_double()), "unit": Json::string(value.unit), }) - } (@model.SchemaTypeBody::Datetime, @model.SchemaValue::Datetime(value)) => Json::string(datetime_to_json(value)) (@model.SchemaTypeBody::Duration, @model.SchemaValue::Duration(value)) => - Json::string(duration_to_json(value)) + Json::object({ "nanoseconds": Json::string(value.to_string()) }) (@model.SchemaTypeBody::Secret(_), @model.SchemaValue::Secret(_)) | (@model.SchemaTypeBody::QuotaToken(_), @model.SchemaValue::QuotaToken(_)) | ( @@ -984,14 +993,11 @@ fn SchemaRef::pack( S8(_) => S8(json_integer(json, -128.0, 127.0).to_int()) S16(_) => S16(json_integer(json, -32768.0, 32767.0).to_int()) S32(_) => S32(json_integer(json, -2147483648.0, 2147483647.0).to_int()) - S64(_) => - S64( - json_integer(json, -9007199254740991.0, 9007199254740991.0).to_int64(), - ) + S64(_) => S64(json_int64_string(json)) U8(_) => U8(json_integer(json, 0.0, 255.0).to_uint().to_byte()) U16(_) => U16(json_integer(json, 0.0, 65535.0).to_uint()) U32(_) => U32(json_integer(json, 0.0, 4294967295.0).to_uint()) - U64(_) => U64(json_integer(json, 0.0, 9007199254740991.0).to_uint64()) + U64(_) => U64(json_uint64_string(json)) F32(_) => { let value = json_number(json) guard !value.is_nan() && !value.is_inf() else { @@ -1027,7 +1033,15 @@ fn SchemaRef::pack( } } let values = expected.map(field => { - self.pack(self.resolve(field.body), required_field(fields, field.name)) + let resolved = self.resolve(field.body) + match fields.get(field.name) { + Some(value) => self.pack(resolved, value) + None => + match resolved.body { + Option(_) => @model.SchemaValue::Option(None) + _ => raise Json("missing field '" + field.name + "'") + } + } }) Record(values) } @@ -1197,11 +1211,7 @@ fn SchemaRef::pack( raise Json("quantity JSON requires mantissa, scale, and unit") } Quantity({ - mantissa: json_integer( - required_field(fields, "mantissa"), - -9007199254740991.0, - 9007199254740991.0, - ).to_int64(), + mantissa: json_int64_string(required_field(fields, "mantissa")), scale: json_integer( required_field(fields, "scale"), -2147483648.0, @@ -1211,7 +1221,13 @@ fn SchemaRef::pack( }) } Datetime => Datetime(datetime_from_json(json_string(json))) - Duration => Duration(duration_from_json(json)) + Duration => { + let fields = json_object(json) + guard fields.length() == 1 else { + raise Json("duration JSON requires only nanoseconds") + } + Duration(json_int64_string(required_field(fields, "nanoseconds"))) + } Secret(_) | QuotaToken(_) | PermissionCard(_) => raise Json("capability values cannot be constructed from JSON") Future(_) | Stream(_) => diff --git a/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt index d9f7becf48..97b55108c6 100644 --- a/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt @@ -42,7 +42,7 @@ test "JSON Schema projects records and recursive definitions" { ]), }), }), - "required": Json::array([Json::string("next")]), + "required": Json::array([]), "additionalProperties": Json::boolean(false), }), }), @@ -126,7 +126,9 @@ test "JSON Schema keeps wide numeric bounds exact in metadata" { Object(fields) => fields _ => fail("expected JSON Schema object") } - assert_eq(fields.get("x-golem-numeric-width"), Some(Json::string("u64"))) + assert_eq(fields.get("type"), Some(Json::string("string"))) + assert_eq(fields.get("format"), Some(Json::string("uint64"))) + assert_eq(fields.get("pattern"), Some(Json::string("^(?:0|[1-9][0-9]*)$"))) assert_eq( fields.get("x-golem-minimum"), Some(Json::string("9007199254740993")), @@ -269,18 +271,36 @@ test "unsigned 32-bit JSON preserves values above the signed range" { } ///| -test "64-bit JSON rejects values outside the safe integer range" { +test "64-bit JSON uses full-range canonical decimal strings" { let signed = test_schema(S64(None)) let unsigned = test_schema(U64(None)) - try signed.unpack_json(@model.SchemaValue::S64(9007199254740992L)) catch { - _ => () - } noraise { - _ => fail("expected unsafe signed integer to be rejected") + assert_eq( + signed.unpack_json(@model.SchemaValue::S64(-9223372036854775807L - 1L)), + Json::string("-9223372036854775808"), + ) + assert_eq( + signed.pack_json(Json::string("9223372036854775807")), + @model.SchemaValue::S64(9223372036854775807L), + ) + assert_eq( + unsigned.unpack_json(@model.SchemaValue::U64(18446744073709551615UL)), + Json::string("18446744073709551615"), + ) + assert_eq( + unsigned.pack_json(Json::string("18446744073709551615")), + @model.SchemaValue::U64(18446744073709551615UL), + ) + for invalid in ["+1", "01", "-0", "9223372036854775808"] { + try signed.pack_json(Json::string(invalid)) catch { + _ => () + } noraise { + _ => fail("expected malformed or overflowing s64 to be rejected") + } } - try unsigned.unpack_json(@model.SchemaValue::U64(9007199254740992UL)) catch { + try unsigned.pack_json(Json::number(1.0)) catch { _ => () } noraise { - _ => fail("expected unsafe unsigned integer to be rejected") + _ => fail("expected numeric u64 JSON to be rejected") } } @@ -403,28 +423,37 @@ test "binary JSON round-trips unpadded base64url and MIME type" { } ///| -test "quantity JSON round-trips within the safe integer range" { +test "quantity JSON uses a full-range canonical mantissa string" { let schema = test_schema( Quantity({ base_unit: "kg", allowed_suffixes: [], min: None, max: None, }), ) let value = @model.SchemaValue::Quantity({ - mantissa: 1234L, + mantissa: -9223372036854775807L - 1L, scale: 2, unit: "kg", }) - assert_eq(schema.pack_json(schema.unpack_json(value)), value) + let json = schema.unpack_json(value) + assert_eq( + json, + Json::object({ + "mantissa": Json::string("-9223372036854775808"), + "scale": Json::number(2.0), + "unit": Json::string("kg"), + }), + ) + assert_eq(schema.pack_json(json), value) try - schema.unpack_json( - @model.SchemaValue::Quantity({ - mantissa: 9007199254740992L, - scale: 0, - unit: "kg", + schema.pack_json( + Json::object({ + "mantissa": Json::string("-0"), + "scale": Json::number(0.0), + "unit": Json::string("kg"), }), ) catch { _ => () } noraise { - _ => fail("expected unsafe quantity mantissa to be rejected") + _ => fail("expected non-canonical quantity mantissa to be rejected") } } @@ -457,28 +486,76 @@ test "datetime JSON round-trips UTC instants and rejects invalid dates" { } ///| -test "duration JSON round-trips ISO strings and accepts shorthand" { +test "duration JSON uses a signed decimal nanoseconds object" { let schema = test_schema(Duration) - let value = @model.SchemaValue::Duration(-90061000000001L) - let json = Json::string("-P1DT1H1M1.000000001S") + let value = @model.SchemaValue::Duration(-9223372036854775807L - 1L) + let json = Json::object({ + "nanoseconds": Json::string("-9223372036854775808"), + }) assert_eq(schema.unpack_json(value), json) assert_eq(schema.pack_json(json), value) - assert_eq( - schema.pack_json(Json::string("1500ms")), - @model.SchemaValue::Duration(1500000000L), - ) - assert_eq( - schema.pack_json(Json::string("-9223372036854775808ns")), - @model.SchemaValue::Duration(-9223372036854775807L - 1L), - ) - assert_eq( - schema.pack_json(Json::object({ "nanoseconds": Json::number(42.0) })), - @model.SchemaValue::Duration(42L), - ) - try schema.pack_json(Json::string("PT9223372037S")) catch { + try schema.pack_json(Json::string("PT1S")) catch { _ => () } noraise { - _ => fail("expected duration overflow to be rejected") + _ => fail("expected non-canonical duration JSON to be rejected") + } + try + schema.pack_json(Json::object({ "nanoseconds": Json::string("-0") })) + catch { + _ => () + } noraise { + _ => fail("expected non-canonical duration integer to be rejected") + } +} + +///| +test "omitted option record fields decode as none" { + let metadata = @model.empty_metadata() + let schema = SchemaRef::new({ + defs: [ + { + id: "optional-string", + name: None, + body: @model.schema_type(Option(@model.schema_type(String))), + }, + ], + root: @model.schema_type( + Record([ + { + name: "direct", + body: @model.schema_type(Option(@model.schema_type(String))), + metadata, + }, + { + name: "referenced", + body: @model.schema_type(Ref("optional-string")), + metadata, + }, + ]), + ), + }) + let absent = @model.SchemaValue::Record([ + @model.SchemaValue::Option(None), + @model.SchemaValue::Option(None), + ]) + assert_eq(schema.pack_json(Json::object({})), absent) + let rendered = schema.to_json_schema(include_draft_marker=false) + guard rendered is Object(fields) else { fail("expected schema object") } + assert_eq(fields.get("required"), Some(Json::array([]))) +} + +///| +test "reflection JSON Schema rejects unrepresentable leaves" { + for + body in [ + @model.SchemaTypeBody::Future(None), + @model.SchemaTypeBody::Stream(None), + @model.SchemaTypeBody::QuotaToken({ resource_name: None, }), + ] { + assert_eq( + test_schema(body).to_json_schema(include_draft_marker=false), + Json::object({ "not": Json::object({}) }), + ) } } diff --git a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tool_clients.mbt b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tool_clients.mbt index 6d872ccb49..fe4e64e290 100644 --- a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tool_clients.mbt +++ b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tool_clients.mbt @@ -37,25 +37,29 @@ pub fn MoonbitStreamingClient::stream( let __golem_def = __golem_tool_def_MoonBitStreaming() let __golem_index = match __golem_def.command_index_by_path(["stream"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] __golem_values.push(("mode", @schema.to_value_as(mode))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.start(["stream"], __golem_input, Some(stdin), true, fn(_, _) { - None - }) { + match self + .client + .start(["stream"], __golem_input, Some(stdin), true, fn(_, _) { None }) { Err(__golem_error) => Err(__golem_error) Ok(__golem_invocation) => @tool.typed_invocation(__golem_invocation, @tool.decode_result_value) @@ -104,14 +108,17 @@ pub fn CanonicalGrepClient::canonical_grep( let __golem_def = __golem_tool_def_CanonicalGrep() let __golem_index = match __golem_def.command_index_by_path([]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] @@ -121,16 +128,18 @@ pub fn CanonicalGrepClient::canonical_grep( __golem_values.push(("extra-patterns", @schema.to_value_as(extra_patterns))) __golem_values.push(("max-count", @schema.to_value_as(max_count))) __golem_values.push(("files", @schema.to_value_as(files))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.start([], __golem_input, Some(stdin), true, fn( - __golem_name, - __golem_value, + match self + .client + .start( + [], __golem_input, Some(stdin), true, fn(__golem_name, __golem_value) { + @tool.decode_named_tool_error(__golem_name, __golem_value) + }, ) { - @tool.decode_named_tool_error(__golem_name, __golem_value) - }) { Err(__golem_error) => Err(__golem_error) Ok(__golem_invocation) => @tool.typed_invocation(__golem_invocation, @tool.decode_result_value) @@ -150,14 +159,17 @@ pub async fn CanonicalGrepClient::replace( let __golem_def = __golem_tool_def_CanonicalGrep() let __golem_index = match __golem_def.command_index_by_path(["replace"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] @@ -166,11 +178,14 @@ pub async fn CanonicalGrepClient::replace( __golem_values.push(("pattern", @schema.to_value_as(pattern))) __golem_values.push(("replacement", @schema.to_value_as(replacement))) __golem_values.push(("files", @schema.to_value_as(files))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error(["replace"], __golem_input, None) { + match self + .client + .invoke_and_await_tool_error(["replace"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_value(__golem_result) } @@ -218,14 +233,17 @@ pub async fn CanonicalGitClient::commit( let __golem_def = __golem_tool_def_CanonicalGit() let __golem_index = match __golem_def.command_index_by_path(["commit"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] @@ -239,11 +257,12 @@ pub async fn CanonicalGitClient::commit( __golem_values.push(("signoff", @schema.to_value_as(signoff))) __golem_values.push(("reset-author", @schema.to_value_as(reset_author))) __golem_values.push(("output", @schema.to_value_as(output))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error(["commit"], __golem_input, None) { + match self.client.invoke_and_await_tool_error(["commit"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_value(__golem_result) } @@ -276,7 +295,7 @@ pub fn CanonicalGitClient::remote( __golem_prefix.push(("git-dir", @schema.to_value_as(git_dir))) __golem_prefix.push(("paginate", @schema.to_value_as(paginate))) __golem_prefix.push(("config", @schema.to_value_as(config))) - { client: self.client, prefix: __golem_prefix, } + { client: self.client, prefix: __golem_prefix } } ///| @@ -291,17 +310,19 @@ pub async fn CanonicalGitRemoteClient::add( fetch : Bool, ) -> Result[Unit, @tool.ToolError[CanonicalRemoteError]] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(["remote", "add"]) { + let __golem_index = match __golem_def.command_index_by_path(["remote", "add"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() @@ -311,15 +332,14 @@ pub async fn CanonicalGitRemoteClient::add( __golem_values.push(("master", @schema.to_value_as(master))) __golem_values.push(("tags", @schema.to_value_as(tags))) __golem_values.push(("fetch", @schema.to_value_as(fetch))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error( - ["remote", "add"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_tool_error(["remote", "add"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -332,30 +352,32 @@ pub async fn CanonicalGitRemoteClient::remove( name : String, ) -> Result[Unit, @tool.ToolError[CanonicalRemoteError]] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(["remote", "remove"]) { - Some(__golem_index) => __golem_index - None => - return Err( - @tool.tool_protocol_error( - "generated command path is missing from descriptor", - ), - ) - } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_index = + match __golem_def.command_index_by_path(["remote", "remove"]) { + Some(__golem_index) => __golem_index + None => { + return Err( + @tool.tool_protocol_error( + "generated command path is missing from descriptor", + ), + ) + } + } + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() __golem_values.push(("name", @schema.to_value_as(name))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error( - ["remote", "remove"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_tool_error(["remote", "remove"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -373,17 +395,20 @@ pub async fn CanonicalGitRemoteClient::set_url( delete : Bool, ) -> Result[Unit, @tool.ToolError[CanonicalSetUrlError]] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(["remote", "set-url"]) { - Some(__golem_index) => __golem_index - None => - return Err( - @tool.tool_protocol_error( - "generated command path is missing from descriptor", - ), - ) - } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_index = + match __golem_def.command_index_by_path(["remote", "set-url"]) { + Some(__golem_index) => __golem_index + None => { + return Err( + @tool.tool_protocol_error( + "generated command path is missing from descriptor", + ), + ) + } + } + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() @@ -393,15 +418,14 @@ pub async fn CanonicalGitRemoteClient::set_url( __golem_values.push(("push", @schema.to_value_as(push))) __golem_values.push(("add", @schema.to_value_as(add))) __golem_values.push(("delete", @schema.to_value_as(delete))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error( - ["remote", "set-url"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_tool_error(["remote", "set-url"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -430,7 +454,7 @@ pub fn CanonicalGitClient::stash( let __golem_prefix = [] __golem_prefix.push(("verbose", @schema.to_value_as(verbose))) __golem_prefix.push(("git-dir", @schema.to_value_as(git_dir))) - { client: self.client, prefix: __golem_prefix, } + { client: self.client, prefix: __golem_prefix } } ///| @@ -443,24 +467,28 @@ pub async fn CanonicalGitStashClient::stash( let __golem_def = __golem_tool_def_CanonicalGit() let __golem_index = match __golem_def.command_index_by_path(["stash"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() __golem_values.push(("message", @schema.to_value_as(message))) __golem_values.push(("keep-index", @schema.to_value_as(keep_index))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error(["stash"], __golem_input, None) { + match self.client.invoke_and_await_tool_error(["stash"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -474,31 +502,32 @@ pub async fn CanonicalGitStashClient::pop( index : UInt?, ) -> Result[Unit, @tool.ToolError[CanonicalStashError]] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(["stash", "pop"]) { + let __golem_index = match __golem_def.command_index_by_path(["stash", "pop"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() __golem_values.push(("name", @schema.to_value_as(name))) __golem_values.push(("index", @schema.to_value_as(index))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error( - ["stash", "pop"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_tool_error(["stash", "pop"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -512,31 +541,33 @@ pub async fn CanonicalGitStashClient::apply( index : UInt?, ) -> Result[Unit, @tool.ToolError[CanonicalStashError]] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(["stash", "apply"]) { - Some(__golem_index) => __golem_index - None => - return Err( - @tool.tool_protocol_error( - "generated command path is missing from descriptor", - ), - ) - } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_index = + match __golem_def.command_index_by_path(["stash", "apply"]) { + Some(__golem_index) => __golem_index + None => { + return Err( + @tool.tool_protocol_error( + "generated command path is missing from descriptor", + ), + ) + } + } + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() __golem_values.push(("name", @schema.to_value_as(name))) __golem_values.push(("index", @schema.to_value_as(index))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error( - ["stash", "apply"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_tool_error(["stash", "apply"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -560,14 +591,17 @@ pub async fn CanonicalGitClient::log( let __golem_def = __golem_tool_def_CanonicalGit() let __golem_index = match __golem_def.command_index_by_path(["log"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] @@ -581,7 +615,9 @@ pub async fn CanonicalGitClient::log( __golem_values.push(("oneline", @schema.to_value_as(oneline))) __golem_values.push(("graph", @schema.to_value_as(graph))) __golem_values.push(("paths", @schema.to_value_as(paths))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } match self.client.invoke_and_await_tool_error(["log"], __golem_input, None) { @@ -604,9 +640,7 @@ pub fn CanonicalBigBoundClient::new() -> CanonicalBigBoundClient { ///| /// -pub fn CanonicalBigBoundClient::new_for( - name : String, -) -> CanonicalBigBoundClient { +pub fn CanonicalBigBoundClient::new_for(name : String) -> CanonicalBigBoundClient { { client: @tool.ToolClient::new(name), } } @@ -625,19 +659,24 @@ pub async fn CanonicalBigBoundClient::canonical_big_bound( let __golem_def = __golem_tool_def_CanonicalBigBound() let __golem_index = match __golem_def.command_index_by_path([]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] __golem_values.push(("count", @schema.to_value_as(count))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } match self.client.invoke_and_await_tool_error([], __golem_input, None) { @@ -660,17 +699,13 @@ pub fn MoonbitReflectionTestClient::new() -> MoonbitReflectionTestClient { ///| /// -pub fn MoonbitReflectionTestClient::new_for( - name : String, -) -> MoonbitReflectionTestClient { +pub fn MoonbitReflectionTestClient::new_for(name : String) -> MoonbitReflectionTestClient { { client: @tool.ToolClient::new(name), } } ///| /// -pub fn MoonbitReflectionTestClient::drop( - self : MoonbitReflectionTestClient, -) -> Unit { +pub fn MoonbitReflectionTestClient::drop(self : MoonbitReflectionTestClient) -> Unit { self.client.drop() } @@ -683,23 +718,29 @@ pub async fn MoonbitReflectionTestClient::echo( let __golem_def = __golem_tool_def_MoonBitReflectionTest() let __golem_index = match __golem_def.command_index_by_path(["echo"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] __golem_values.push(("label", @schema.to_value_as(label))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_no_tool_error(["echo"], __golem_input, None) { + match self + .client + .invoke_and_await_no_tool_error(["echo"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_value(__golem_result) } @@ -714,27 +755,75 @@ pub async fn MoonbitReflectionTestClient::optional( let __golem_def = __golem_tool_def_MoonBitReflectionTest() let __golem_index = match __golem_def.command_index_by_path(["optional"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] __golem_values.push(("maybe", @schema.to_value_as(maybe))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_no_tool_error( - ["optional"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_no_tool_error(["optional"], __golem_input, None) { + Err(__golem_error) => Err(__golem_error) + Ok(__golem_result) => @tool.decode_result_value(__golem_result) + } +} + +///| +/// +pub async fn MoonbitReflectionTestClient::canonical_values( + self : MoonbitReflectionTestClient, + signed : Int64, + unsigned : UInt64, + duration : @schema.Duration, + quantity : @schema.Quantity[ReflectionMeters], + maybe : String?, +) -> Result[String, @tool.ToolError[@tool.NoToolError]] { + let __golem_def = __golem_tool_def_MoonBitReflectionTest() + let __golem_index = + match __golem_def.command_index_by_path(["canonical-values"]) { + Some(__golem_index) => __golem_index + None => { + return Err( + @tool.tool_protocol_error( + "generated command path is missing from descriptor", + ), + ) + } + } + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ + __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) + } + let __golem_values = [] + __golem_values.push(("signed", @schema.to_value_as(signed))) + __golem_values.push(("unsigned", @schema.to_value_as(unsigned))) + __golem_values.push(("duration", @schema.to_value_as(duration))) + __golem_values.push(("quantity", @schema.to_value_as(quantity))) + __golem_values.push(("maybe", @schema.to_value_as(maybe))) + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ + __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) + } + match self + .client + .invoke_and_await_no_tool_error(["canonical-values"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_value(__golem_result) } diff --git a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tools.mbt b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tools.mbt index 4f7847df3d..fe617cbdd4 100644 --- a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tools.mbt +++ b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tools.mbt @@ -4406,7 +4406,7 @@ fn __golem_tool_def_raw_MoonBitReflectionTest() -> @tool.ToolDef { aliases: [], doc: { summary: "", description: "", examples: [] }, globals: { options: [], flags: [] }, - subcommands: [1, 2], + subcommands: [1, 2, 3], body: None, }, { @@ -4522,6 +4522,117 @@ fn __golem_tool_def_raw_MoonBitReflectionTest() -> @tool.ToolDef { }, ), }, + { + name: "canonical-values", + aliases: [], + doc: { summary: "", description: "", examples: [] }, + globals: { options: [], flags: [] }, + subcommands: [], + body: Some( + { + positionals: { + fixed: [ + { + name: "signed", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[Int64]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + { + name: "unsigned", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[UInt64]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + { + name: "duration", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + ( + @schema.TypeTag::{ } + : @schema.TypeTag[@schema.Duration] + ), + ), + default: None, + required: true, + accepts_stdio: false, + }, + { + name: "quantity", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + ( + @schema.TypeTag::{ } + : @schema.TypeTag[@schema.Quantity[ReflectionMeters]] + ), + ), + default: None, + required: true, + accepts_stdio: false, + }, + ], + tail: None, + }, + options: [ + { + long: "maybe", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + ), + default: None, + required: false, + env_var: None, + }, + ], + flags: [], + constraints: [], + stdin: None, + stdout: None, + result: Some( + { + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + doc: { summary: "", description: "", examples: [] }, + formatters: [ + { + name: "default", + doc: { summary: "", description: "", examples: [] }, + }, + ], + default_formatter: "default", + }, + ), + errors: [], + annotations: Some( + { + read_only: false, + destructive: true, + idempotent: false, + open_world: true, + }, + ), + }, + ), + }, ], } def @@ -4715,6 +4826,119 @@ async fn __golem_tool_invoke_MoonBitReflectionTest( }, ) } + 3 => { + if __golem_stdin is Some(_) { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + @types.ToolError::InvalidInput( + "command does not accept a stdin stream", + ), + ) + } + if __golem_stdout is Some(_) { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + @types.ToolError::InvalidInput( + "command does not accept a stdout stream", + ), + ) + } + let signed :Int64 = try { + @tool.decode_canonical_field( + __golem_decoded, + "signed", + "tool moonbit-reflection-test command canonical-values", + ) + } catch{ + __golem_error => { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } + } + let unsigned :UInt64 = try { + @tool.decode_canonical_field( + __golem_decoded, + "unsigned", + "tool moonbit-reflection-test command canonical-values", + ) + } catch{ + __golem_error => { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } + } + let duration :@schema.Duration = try { + @tool.decode_canonical_field( + __golem_decoded, + "duration", + "tool moonbit-reflection-test command canonical-values", + ) + } catch{ + __golem_error => { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } + } + let quantity :@schema.Quantity[ReflectionMeters] = try { + @tool.decode_canonical_field( + __golem_decoded, + "quantity", + "tool moonbit-reflection-test command canonical-values", + ) + } catch{ + __golem_error => { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } + } + let maybe :String? = try { + @tool.decode_canonical_field( + __golem_decoded, + "maybe", + "tool moonbit-reflection-test command canonical-values", + ) + } catch{ + __golem_error => { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } + } + let __golem_value = MoonBitReflectionTest::canonical_values( + signed, + unsigned, + duration, + quantity, + maybe, + ) + Ok( + try { + @tool.invocation_result_value(__golem_value) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { + return Err(@types.ToolError::InvalidResult(message)) + } + error => raise error + }, + ) + } _ => { @tool.reject_decoded_invocation( __golem_decoded, diff --git a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt index 401585d2df..92cdca03df 100644 --- a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt +++ b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt @@ -2,6 +2,25 @@ #derive.tool("moonbit-reflection-test") struct MoonBitReflectionTest {} +///| +#warnings("-struct_never_constructed") +struct ReflectionMeters {} + +///| +impl @schema.QuantityUnit for ReflectionMeters with fn type_id() { + "golem.examples.ReflectionMeters" +} + +///| +impl @schema.QuantityUnit for ReflectionMeters with fn base_unit() { + "m" +} + +///| +impl @schema.QuantityUnit for ReflectionMeters with fn allowed_suffixes() { + [] +} + ///| #derive.golem_schema pub(all) struct AuditLogConstructor { @@ -37,6 +56,29 @@ pub fn MoonBitReflectionTest::optional(maybe : String?) -> String { maybe.unwrap_or("omitted") } +///| +#derive.arg("maybe", scope="option") +pub fn MoonBitReflectionTest::canonical_values( + signed : Int64, + unsigned : UInt64, + duration : @schema.Duration, + quantity : @schema.Quantity[ReflectionMeters], + maybe : String?, +) -> String { + let quantity = quantity.value() + if signed == -9223372036854775807L - 1L && + unsigned == 18446744073709551615UL && + duration == @schema.Duration::new(9223372036854775807L) && + quantity.mantissa == -9223372036854775807L - 1L && + quantity.scale == -9 && + quantity.unit == "m" && + maybe is None { + "moonbit-canonical-ok" + } else { + "moonbit-canonical-mismatch" + } +} + ///| fn reflected_tool_error( error : @tool.ToolError[@reflection.ReflectedToolCustomError], @@ -254,9 +296,8 @@ pub async fn MoonBitToolReflectionCaller::optional_round_trip( let command = tool.command(["optional"]) catch { error => return "command:\{Repr(error)}" } - let omitted_json = command.invoke_json( - Json::object({ "maybe": Json::null() }), - ) + let omitted_json = command.invoke_json(Json::object({})) + let null_json = command.invoke_json(Json::object({ "maybe": Json::null() })) let supplied_json = command.invoke_json( Json::object({ "maybe": Json::string("supplied") }), ) @@ -299,7 +340,39 @@ pub async fn MoonBitToolReflectionCaller::optional_round_trip( Ok(value) => value Err(_) => return "generated-supplied-error" } - "\{text_json(omitted_json)}|\{text_json(supplied_json)}|\{text_native(omitted_native)}|\{text_native(supplied_native)}|\{generated_omitted}|\{generated_supplied}" + "\{text_json(omitted_json)}|\{text_json(null_json)}|\{text_json(supplied_json)}|\{text_native(omitted_native)}|\{text_native(supplied_native)}|\{generated_omitted}|\{generated_supplied}" +} + +///| +pub async fn MoonBitToolReflectionCaller::canonical_round_trip( + self : Self, +) -> String { + ignore(self) + let tool = @reflection.get_tool_type("moonbit-reflection-test") catch { + error => return "discovery:\{Repr(error)}" + } + let command = tool.command(["canonical-values"]) catch { + error => return "command:\{Repr(error)}" + } + let result = command.invoke_json( + Json::object({ + "signed": Json::string("-9223372036854775808"), + "unsigned": Json::string("18446744073709551615"), + "duration": Json::object({ + "nanoseconds": Json::string("9223372036854775807"), + }), + "quantity": Json::object({ + "mantissa": Json::string("-9223372036854775808"), + "scale": Json::number(-9.0), + "unit": Json::string("m"), + }), + }), + ) + match result { + Ok(Some(Json::String(text))) => text + Ok(_) => "canonical-error:unexpected" + Err(error) => "canonical-error:\{reflected_tool_error(error)}" + } } ///| From 876270bcadedc850a9dd7c24bb57fcf2a942382b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 14:43:28 +0200 Subject: [PATCH 12/37] test: enforce cross-sdk reflection conformance --- .github/workflows/ci.yaml | 2 + Makefile.toml | 14 + golem-schema/src/schema/render/tests.rs | 393 +++++++++++++- scripts/sync-reflection-conformance.mjs | 87 ++++ .../test/reflection-conformance.test.ts | 195 +++++++ .../reflection_conformance_corpus_wbtest.mbt | 12 + .../reflection_conformance_wbtest.mbt | 291 +++++++++++ .../rust/golem-rust/src/agentic/reflection.rs | 294 ++++++++++- .../ReflectionConformanceCorpus.scala | 485 ++++++++++++++++++ .../ReflectionConformanceSpec.scala | 264 ++++++++++ .../tests/reflection-conformance.test.ts | 210 ++++++++ test-data/reflection-conformance/v1.json | 293 +++++++++++ 12 files changed, 2529 insertions(+), 11 deletions(-) create mode 100644 scripts/sync-reflection-conformance.mjs create mode 100644 sdks/effect/test/reflection-conformance.test.ts create mode 100644 sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt create mode 100644 sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt create mode 100644 sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala create mode 100644 sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala create mode 100644 sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts create mode 100644 test-data/reflection-conformance/v1.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 109c0c8f4a..c448e8bb56 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -138,6 +138,8 @@ jobs: run: cargo make --profile ci check-openapi - name: Check how-to-guides docs are in sync with golem-skills/ run: cargo make --profile ci check-docs-skills + - name: Check reflection conformance corpus embeds + run: cargo make --profile ci check-reflection-conformance - name: Check configs are up to date run: cargo make --profile ci check-configs - name: Check WIT deps are synced diff --git a/Makefile.toml b/Makefile.toml index 7db6198239..82089110db 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -1136,6 +1136,20 @@ script = ''' fi ''' +[tasks.generate-reflection-conformance] +description = "Embeds the reflection conformance corpus in Scala and MoonBit test sources" +script_runner = "@duckscript" +script = ''' +exec --fail-on-error node scripts/sync-reflection-conformance.mjs +''' + +[tasks.check-reflection-conformance] +description = "Fails if Scala or MoonBit reflection conformance embeds are stale" +script_runner = "@duckscript" +script = ''' +exec --fail-on-error node scripts/sync-reflection-conformance.mjs --check +''' + ## ** PUBLISH ** [tasks.publish] description = "Publishes packages to crates.io" diff --git a/golem-schema/src/schema/render/tests.rs b/golem-schema/src/schema/render/tests.rs index 4cf1df7e03..b9e2555812 100644 --- a/golem-schema/src/schema/render/tests.rs +++ b/golem-schema/src/schema/render/tests.rs @@ -12,12 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::{RenderError, from_json_value, to_json_schema, to_json_value}; +use super::{ + RenderError, from_json_value, to_json_schema, to_json_value, to_reflection_json_schema, +}; +use crate::schema::schema_type::{NumericBound, NumericRestrictions}; +use crate::schema::validation::{is_equivalent_cross_graph, validate_graph, validate_value}; use crate::schema::{ - MetadataEnvelope, NamedFieldType, SchemaGraph, SchemaType, SchemaTypeDef, SchemaValue, + DurationValuePayload, MetadataEnvelope, NamedFieldType, PermissionCardSpec, QuantitySpec, + QuantityValue, QuotaTokenSpec, ResultSpec, SchemaGraph, SchemaType, SchemaTypeDef, SchemaValue, TextRestrictions, TextValuePayload, TypeId, VariantCaseType, VariantValuePayload, }; +use proptest::prelude::*; use serde_json::{Value, json}; +use std::collections::HashSet; use test_r::test; #[test] @@ -154,6 +161,82 @@ fn wide_integer_json_rejects_noncanonical_or_out_of_range_strings() { } } +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + #[test] + fn wide_and_rich_numeric_json_round_trips_without_precision_loss( + signed in any::(), + unsigned in any::(), + scale in any::(), + ) { + let signed_type = SchemaType::s64(); + let signed_graph = SchemaGraph::anonymous(signed_type.clone()); + let signed_json = json!(signed.to_string()); + prop_assert_eq!( + from_json_value(&signed_graph, &signed_type, &signed_json), + Ok(SchemaValue::S64(signed)) + ); + + let unsigned_type = SchemaType::u64(); + let unsigned_graph = SchemaGraph::anonymous(unsigned_type.clone()); + let unsigned_json = json!(unsigned.to_string()); + prop_assert_eq!( + from_json_value(&unsigned_graph, &unsigned_type, &unsigned_json), + Ok(SchemaValue::U64(unsigned)) + ); + + let duration_type = SchemaType::duration(); + let duration_graph = SchemaGraph::anonymous(duration_type.clone()); + let duration_json = json!({ "nanoseconds": signed.to_string() }); + prop_assert_eq!( + from_json_value(&duration_graph, &duration_type, &duration_json), + Ok(SchemaValue::Duration(DurationValuePayload { nanoseconds: signed })) + ); + + let quantity_type = SchemaType::quantity(QuantitySpec { + base_unit: "m".to_string(), + allowed_suffixes: Vec::new(), + min: None, + max: None, + }); + let quantity_graph = SchemaGraph::anonymous(quantity_type.clone()); + let quantity_json = json!({ + "mantissa": signed.to_string(), + "scale": scale, + "unit": "m", + }); + prop_assert_eq!( + from_json_value(&quantity_graph, &quantity_type, &quantity_json), + Ok(SchemaValue::Quantity(QuantityValue { + mantissa: signed, + scale, + unit: "m".to_string(), + })) + ); + } + + #[test] + fn decimal_string_mutations_are_rejected(value in any::()) { + let signed_type = SchemaType::s64(); + let signed_graph = SchemaGraph::anonymous(signed_type.clone()); + let unsigned_type = SchemaType::u64(); + let unsigned_graph = SchemaGraph::anonymous(unsigned_type.clone()); + let digits = value.to_string(); + + for mutated in [format!("+{digits}"), format!("0{digits}"), "-0".to_string()] { + prop_assert!( + from_json_value(&signed_graph, &signed_type, &json!(mutated)).is_err() + ); + } + for mutated in [format!("+{digits}"), format!("0{digits}"), format!("-{digits}")] { + prop_assert!( + from_json_value(&unsigned_graph, &unsigned_type, &json!(mutated)).is_err() + ); + } + } +} + #[test] fn refs_variants_and_options_share_one_graph() { let payload_id = TypeId::new("example.payload"); @@ -210,6 +293,58 @@ fn refs_variants_and_options_share_one_graph() { assert!(!required.contains(&Value::String("value".to_string()))); } +#[test] +fn omitted_option_record_fields_decode_as_none() { + let option_id = TypeId::new("example.optional-string"); + let ty = SchemaType::record(vec![ + NamedFieldType { + name: "direct".to_string(), + body: SchemaType::option(SchemaType::string()), + metadata: MetadataEnvelope::default(), + }, + NamedFieldType { + name: "referenced".to_string(), + body: SchemaType::ref_to(option_id.clone()), + metadata: MetadataEnvelope::default(), + }, + ]); + let graph = SchemaGraph { + defs: vec![SchemaTypeDef { + id: option_id, + name: None, + body: SchemaType::option(SchemaType::string()), + }], + root: ty.clone(), + }; + let expected = SchemaValue::Record { + fields: vec![ + SchemaValue::Option { inner: None }, + SchemaValue::Option { inner: None }, + ], + }; + + assert_eq!(from_json_value(&graph, &ty, &json!({})).unwrap(), expected); + assert_eq!( + from_json_value(&graph, &ty, &json!({ "direct": null, "referenced": null })).unwrap(), + expected + ); +} + +#[test] +fn reflection_json_schema_rejects_unrepresentable_leaves() { + for ty in [ + SchemaType::secret(crate::schema::SecretSpec::default()), + SchemaType::future(None), + SchemaType::stream(None), + ] { + let graph = SchemaGraph::anonymous(ty.clone()); + assert_eq!( + to_reflection_json_schema(&graph, &ty, false)["not"], + json!({}) + ); + } +} + #[test] fn malformed_json_and_schema_values_are_typed_errors() { let ty = SchemaType::record(vec![NamedFieldType { @@ -227,3 +362,257 @@ fn malformed_json_and_schema_values_are_typed_errors() { .expect_err("wrong value shape must fail"); assert!(matches!(mismatch, RenderError::ValueMismatch { .. })); } + +fn conformance_fixture(name: &str) -> SchemaGraph { + let root = match name { + "s64" => SchemaType::s64(), + "u64" => SchemaType::u64(), + "duration" => SchemaType::duration(), + "quantity" => SchemaType::quantity(QuantitySpec { + base_unit: "m".to_string(), + allowed_suffixes: vec![], + min: None, + max: None, + }), + "tool-input" => SchemaType::record(vec![ + field("pattern", SchemaType::string()), + field("paths", SchemaType::list(SchemaType::string())), + field("ignoreCase", SchemaType::option(SchemaType::bool())), + ]), + "constrained-u32" => SchemaType::U32 { + restrictions: Some(NumericRestrictions { + min: None, + max: Some(NumericBound::Unsigned(10)), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + "result" => SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: Some(Box::new(SchemaType::u32())), + }), + "custom-error" => SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: Some(Box::new(SchemaType::record(vec![ + field("code", SchemaType::string()), + field("retryable", SchemaType::bool()), + ]))), + }), + "optional-record" => { + let id = TypeId::new("conformance.optional"); + return SchemaGraph { + defs: vec![SchemaTypeDef { + id: id.clone(), + name: None, + body: SchemaType::option(SchemaType::string()), + }], + root: SchemaType::record(vec![ + field("direct", SchemaType::option(SchemaType::string())), + field("referenced", SchemaType::ref_to(id)), + ]), + }; + } + other => panic!("unknown conformance fixture {other}"), + }; + SchemaGraph::anonymous(root) +} + +fn field(name: &str, body: SchemaType) -> NamedFieldType { + NamedFieldType { + name: name.to_string(), + body, + metadata: MetadataEnvelope::default(), + } +} + +fn assert_json_subset(actual: &Value, expected: &Value) { + match expected { + Value::Object(expected) => { + let actual = actual + .as_object() + .unwrap_or_else(|| panic!("expected object {expected:?}, got {actual}")); + for (key, expected) in expected { + let actual = actual + .get(key) + .unwrap_or_else(|| panic!("missing key {key} in {actual:?}")); + assert_json_subset(actual, expected); + } + } + _ => assert_eq!(actual, expected), + } +} + +fn json_pointer<'a>(value: &'a Value, pointer: &str) -> &'a Value { + if pointer.is_empty() { + value + } else { + value + .pointer(pointer) + .unwrap_or_else(|| panic!("missing JSON pointer {pointer} in {value}")) + } +} + +fn assert_semantic_conformance(fixture: &str, expected: &Value) { + match fixture { + "unsupported-leaves" => { + let types = [ + SchemaType::secret(Default::default()), + SchemaType::quota_token(QuotaTokenSpec::default()), + SchemaType::permission_card(PermissionCardSpec::default()), + SchemaType::future(None), + SchemaType::stream(None), + ]; + assert_eq!(types.len(), expected["count"].as_u64().unwrap() as usize); + for ty in types { + let graph = SchemaGraph::anonymous(ty.clone()); + assert_json_subset( + &to_reflection_json_schema(&graph, &ty, false), + &expected["schema"], + ); + } + } + "all-kinds" => { + const KINDS: &[&str] = &[ + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream", + ]; + assert_eq!(serde_json::to_value(KINDS).unwrap(), expected["names"]); + } + "all-restrictions" => { + const RESTRICTIONS: &[&str] = &[ + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field", + ]; + assert_eq!( + serde_json::to_value(RESTRICTIONS).unwrap(), + expected["names"] + ); + } + "graph" => { + let referenced = conformance_fixture("optional-record"); + let inline = SchemaGraph::anonymous(SchemaType::record(vec![ + field("direct", SchemaType::option(SchemaType::string())), + field("referenced", SchemaType::option(SchemaType::string())), + ])); + assert!(validate_graph(&referenced).is_ok()); + assert!(is_equivalent_cross_graph( + &referenced, + &referenced.root, + &inline, + &inline.root, + )); + } + other => panic!("unknown semantic conformance fixture {other}"), + } +} + +#[test] +fn reflection_conformance_corpus() { + let corpus: Value = serde_json::from_str(include_str!( + "../../../../test-data/reflection-conformance/v1.json" + )) + .expect("valid reflection conformance corpus"); + assert_eq!(corpus["version"], "1.0.0"); + + let cases = corpus["cases"].as_array().expect("cases array"); + let mut executed = HashSet::new(); + for case in cases { + let id = case["id"].as_str().expect("case id"); + assert!(executed.insert(id), "duplicate case ID {id}"); + let fixture = case["fixture"].as_str().expect("fixture"); + let expected = &case["expected"]; + match case["operation"].as_str().expect("operation") { + "roundtrip" => { + let graph = conformance_fixture(fixture); + let packed = from_json_value(&graph, &graph.root, &case["input"]) + .unwrap_or_else(|error| panic!("{id}: {error}")); + let rendered = to_json_value(&graph, &graph.root, &packed) + .unwrap_or_else(|error| panic!("{id}: {error}")); + assert_eq!(&rendered, expected, "{id}"); + } + "reject" => { + let graph = conformance_fixture(fixture); + let decoded = from_json_value(&graph, &graph.root, &case["input"]); + let rejected = match fixture { + "constrained-u32" => decoded + .as_ref() + .is_ok_and(|value| validate_value(&graph, &graph.root, value).is_err()), + _ => decoded.is_err(), + }; + assert!(rejected, "{id} was accepted"); + } + "json-schema" => { + let graph = conformance_fixture(fixture); + let rendered = to_reflection_json_schema(&graph, &graph.root, false); + let selected = json_pointer(&rendered, case["path"].as_str().expect("path")); + assert_json_subset(selected, expected); + } + "semantic" => assert_semantic_conformance(fixture, expected), + operation => panic!("unknown conformance operation {operation} for {id}"), + } + } + let declared: HashSet<_> = corpus["caseIds"] + .as_array() + .expect("declared case IDs") + .iter() + .map(|id| id.as_str().expect("declared case ID")) + .collect(); + assert_eq!(executed, declared, "missing or unknown conformance cases"); +} diff --git a/scripts/sync-reflection-conformance.mjs b/scripts/sync-reflection-conformance.mjs new file mode 100644 index 0000000000..e357abe864 --- /dev/null +++ b/scripts/sync-reflection-conformance.mjs @@ -0,0 +1,87 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const corpusPath = path.join(root, 'test-data/reflection-conformance/v1.json'); +const scalaPath = path.join( + root, + 'sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala', +); +const moonBitPath = path.join( + root, + 'sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt', +); + +const source = await readFile(corpusPath, 'utf8'); +const corpus = JSON.parse(source); +const normalized = `${JSON.stringify(corpus, null, 2)}\n`; + +if (corpus.version !== '1.0.0') throw new Error(`unexpected corpus version ${corpus.version}`); +if (corpus.schemaKinds.length !== 37) throw new Error('the corpus must enumerate all 37 schema kinds'); +if (corpus.restrictionKinds.length !== 24) + throw new Error('the corpus must enumerate all 24 restriction kinds'); + +const ids = corpus.cases.map((testCase) => testCase.id); +if (new Set(ids).size !== ids.length) throw new Error('conformance case IDs must be unique'); +if (new Set(corpus.caseIds).size !== corpus.caseIds.length) + throw new Error('declared conformance case IDs must be unique'); +if (JSON.stringify([...ids].sort()) !== JSON.stringify([...corpus.caseIds].sort())) + throw new Error('cases must exactly match the declared conformance case-ID set'); +for (const testCase of corpus.cases) { + for (const field of ['id', 'operation', 'fixture', 'expected']) { + if (!(field in testCase)) throw new Error(`${testCase.id ?? ''} is missing ${field}`); + } +} + +const scala = `/* + * Copyright 2024-2026 Golem Cloud + * + * Licensed under the Golem Source License v1.1 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://license.golem.cloud/LICENSE + */ + +package golem.reflection + +// Generated by scripts/sync-reflection-conformance.mjs. Do not edit. +private[reflection] object ReflectionConformanceCorpus { + val json: String = """${normalized}""" +} +`; + +const moonBit = `// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE + +// Generated by scripts/sync-reflection-conformance.mjs. Do not edit. + +///| +let reflection_conformance_corpus_json : String = ${JSON.stringify(normalized)} +`; + +const generated = [ + [scalaPath, scala], + [moonBitPath, moonBit], +]; + +if (process.argv.includes('--check')) { + const stale = []; + for (const [file, expected] of generated) { + const actual = await readFile(file, 'utf8').catch(() => undefined); + if (actual !== expected) stale.push(path.relative(root, file)); + } + if (stale.length !== 0) { + throw new Error( + `reflection conformance embeds are stale: ${stale.join(', ')}; run node scripts/sync-reflection-conformance.mjs`, + ); + } +} else { + await Promise.all(generated.map(([file, contents]) => writeFile(file, contents))); +} diff --git a/sdks/effect/test/reflection-conformance.test.ts b/sdks/effect/test/reflection-conformance.test.ts new file mode 100644 index 0000000000..0636d748ed --- /dev/null +++ b/sdks/effect/test/reflection-conformance.test.ts @@ -0,0 +1,195 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "@effect/vitest" +import { SchemaRef, type JsonValue } from "../src/SchemaRef.js" +import { field, t, type SchemaGraph, type SchemaType } from "../src/internal/schema-model/model.js" +import { schemaGraphToWit } from "../src/internal/schema-model/wit.js" + +interface ConformanceCase { + id: string + operation: "roundtrip" | "reject" | "json-schema" | "semantic" + fixture: string + input?: JsonValue + path?: string + expected: JsonValue +} + +interface ConformanceCorpus { + version: string + schemaKinds: string[] + restrictionKinds: string[] + caseIds: string[] + cases: ConformanceCase[] +} + +const corpus = JSON.parse( + readFileSync( + new URL("../../../test-data/reflection-conformance/v1.json", import.meta.url), + "utf8", + ), +) as ConformanceCorpus + +const supportedSchemaKinds = [ + "ref", "bool", "s8", "s16", "s32", "s64", "u8", "u16", "u32", "u64", + "f32", "f64", "char", "string", "record", "variant", "enum", "flags", "tuple", + "list", "fixed-list", "map", "option", "result", "text", "binary", "path", "url", + "datetime", "duration", "quantity", "union", "secret", "quota-token", + "permission-card", "future", "stream", +] as const + +const supportedRestrictionKinds = [ + "numeric-minimum", "numeric-maximum", "numeric-unit", + "text-languages", "text-min-length", "text-max-length", "text-regex", + "binary-mime-types", "binary-min-bytes", "binary-max-bytes", + "path-direction", "path-kind", "path-mime-types", "path-extensions", + "url-schemes", "url-hosts", "quantity-base-unit", "quantity-suffixes", + "quantity-minimum", "quantity-maximum", + "union-prefix", "union-suffix", "union-regex", "union-field", +] as const + +const ref = (root: SchemaType) => new SchemaRef(schemaGraphToWit({ defs: new Map(), root })) + +function fixture(name: string): SchemaRef { + switch (name) { + case "s64": + return ref(t.s64()) + case "u64": + return ref(t.u64()) + case "duration": + return ref(t.duration()) + case "quantity": + return ref(t.quantity({ baseUnit: "m", allowedSuffixes: [] })) + case "optional-record": { + const graph: SchemaGraph = { + defs: new Map([["conformance.optional", { body: t.option(t.string()) }]]), + root: t.record([ + field("direct", t.option(t.string())), + field("referenced", t.ref("conformance.optional")), + ]), + } + return new SchemaRef(schemaGraphToWit(graph)) + } + case "tool-input": + return ref( + t.record([ + field("pattern", t.string()), + field("paths", t.list(t.string())), + field("ignoreCase", t.option(t.bool())), + ]), + ) + case "constrained-u32": + return ref(t.u32({ max: { tag: "unsigned", val: 10n } })) + case "result": + return ref(t.result(t.string(), t.u32())) + case "custom-error": + return ref( + t.result(t.string(), t.record([field("code", t.string()), field("retryable", t.bool())])), + ) + default: + throw new Error(`unknown conformance fixture ${name}`) + } +} + +function atPointer(value: JsonValue, pointer: string): JsonValue { + if (pointer === "") return value + return pointer + .slice(1) + .split("/") + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce((current, part) => { + if (current === null || typeof current !== "object" || Array.isArray(current)) { + throw new Error(`cannot resolve ${pointer}`) + } + const next = current[part] + if (next === undefined) throw new Error(`missing ${pointer}`) + return next + }, value) +} + +function expectSubset(actual: JsonValue, expected: JsonValue): void { + if (expected !== null && typeof expected === "object" && !Array.isArray(expected)) { + expect(actual).not.toBeNull() + expect(Array.isArray(actual)).toBe(false) + for (const [key, value] of Object.entries(expected)) { + expectSubset((actual as Record)[key], value) + } + } else { + expect(actual).toEqual(expected) + } +} + +function assertSemantic(testCase: ConformanceCase): void { + const expected = testCase.expected as Record + switch (testCase.fixture) { + case "unsupported-leaves": { + const unsupported = [ + t.secret(t.string()), + t.quotaToken({}), + t.permissionCard({ polymorphic: false }), + t.future(t.string()), + t.stream(t.string()), + ] + expect(unsupported).toHaveLength(expected.count as number) + for (const type of unsupported) expectSubset(ref(type).toJsonSchema(), expected.schema) + break + } + case "all-kinds": + expect(supportedSchemaKinds).toEqual(expected.names) + expect(corpus.schemaKinds).toEqual(expected.names) + break + case "all-restrictions": + expect(supportedRestrictionKinds).toEqual(expected.names) + expect(corpus.restrictionKinds).toEqual(expected.names) + break + case "graph": { + const referenced = fixture("optional-record") + const inline = ref( + t.record([ + field("direct", t.option(t.string())), + field("referenced", t.option(t.string())), + ]), + ) + expect(referenced.packJson({})).toEqual(inline.packJson({})) + expect(referenced.validateJson({}).success).toBe(true) + break + } + default: + throw new Error(`unknown semantic conformance fixture ${testCase.fixture}`) + } +} + +describe("reflection conformance corpus", () => { + it("executes the complete declared case-ID set", () => { + expect(corpus.version).toBe("1.0.0") + const executed = new Set() + for (const testCase of corpus.cases) { + expect(executed.has(testCase.id), `duplicate case ID ${testCase.id}`).toBe(false) + executed.add(testCase.id) + switch (testCase.operation) { + case "roundtrip": { + const schema = fixture(testCase.fixture) + expect(schema.unpackJson(schema.packJson(testCase.input!)), testCase.id).toEqual( + testCase.expected, + ) + break + } + case "reject": + expect(fixture(testCase.fixture).validateJson(testCase.input!).success, testCase.id).toBe( + false, + ) + break + case "json-schema": + expectSubset( + atPointer(fixture(testCase.fixture).toJsonSchema(), testCase.path ?? ""), + testCase.expected, + ) + break + case "semantic": + assertSemantic(testCase) + break + default: + throw new Error(`unknown conformance operation for ${testCase.id}`) + } + } + expect([...executed].sort()).toEqual([...corpus.caseIds].sort()) + }) +}) diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt new file mode 100644 index 0000000000..c22ea1e05d --- /dev/null +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt @@ -0,0 +1,12 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE + +// Generated by scripts/sync-reflection-conformance.mjs. Do not edit. + +///| +let reflection_conformance_corpus_json : String = "{\n \"version\": \"1.0.0\",\n \"schemaKinds\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ],\n \"restrictionKinds\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ],\n \"caseIds\": [\n \"canonical/s64-min\",\n \"canonical/s64-max\",\n \"canonical/u64-max\",\n \"canonical/duration-min\",\n \"canonical/duration-max\",\n \"canonical/quantity-wide-mantissa\",\n \"canonical/omitted-options\",\n \"errors/s64-plus\",\n \"errors/s64-leading-zero\",\n \"errors/s64-negative-zero\",\n \"errors/s64-overflow\",\n \"errors/u64-negative\",\n \"errors/u64-overflow\",\n \"errors/wide-wrong-json-type\",\n \"errors/duration-legacy-string\",\n \"errors/quantity-negative-zero\",\n \"json-schema/s64\",\n \"json-schema/u64\",\n \"json-schema/duration-nanoseconds\",\n \"json-schema/quantity-mantissa\",\n \"json-schema/optional-required\",\n \"json-schema/unsupported-leaves\",\n \"schema/all-kinds\",\n \"schema/all-restrictions\",\n \"graph/references-validation-equivalence\",\n \"tool/canonical-input\",\n \"constraints/rejected-value\",\n \"compatibility/result\",\n \"compatibility/custom-error\"\n ],\n \"cases\": [\n {\n \"id\": \"canonical/s64-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"-9223372036854775808\",\n \"expected\": \"-9223372036854775808\"\n },\n {\n \"id\": \"canonical/s64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775807\",\n \"expected\": \"9223372036854775807\"\n },\n {\n \"id\": \"canonical/u64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551615\",\n \"expected\": \"18446744073709551615\"\n },\n {\n \"id\": \"canonical/duration-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"-9223372036854775808\"\n },\n \"expected\": {\n \"nanoseconds\": \"-9223372036854775808\"\n }\n },\n {\n \"id\": \"canonical/duration-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"9223372036854775807\"\n },\n \"expected\": {\n \"nanoseconds\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"canonical/quantity-wide-mantissa\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n }\n },\n {\n \"id\": \"canonical/omitted-options\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"optional-record\",\n \"input\": {},\n \"expected\": {\n \"direct\": null,\n \"referenced\": null\n }\n },\n {\n \"id\": \"errors/s64-plus\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"+1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-leading-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"01\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"-0\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775808\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-negative\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"-1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551616\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/wide-wrong-json-type\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": 1,\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/duration-legacy-string\",\n \"operation\": \"reject\",\n \"fixture\": \"duration\",\n \"input\": \"PT1S\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-0\",\n \"scale\": 0,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"json-schema/s64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/u64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"u64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"uint64\",\n \"pattern\": \"^(?:0|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"0\",\n \"x-golem-maximum\": \"18446744073709551615\"\n }\n },\n {\n \"id\": \"json-schema/duration-nanoseconds\",\n \"operation\": \"json-schema\",\n \"fixture\": \"duration\",\n \"path\": \"/properties/nanoseconds\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/quantity-mantissa\",\n \"operation\": \"json-schema\",\n \"fixture\": \"quantity\",\n \"path\": \"/properties/mantissa\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/optional-required\",\n \"operation\": \"json-schema\",\n \"fixture\": \"optional-record\",\n \"path\": \"/required\",\n \"expected\": []\n },\n {\n \"id\": \"json-schema/unsupported-leaves\",\n \"operation\": \"semantic\",\n \"fixture\": \"unsupported-leaves\",\n \"expected\": {\n \"count\": 5,\n \"schema\": {\n \"not\": {}\n }\n }\n },\n {\n \"id\": \"schema/all-kinds\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-kinds\",\n \"expected\": {\n \"names\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ]\n }\n },\n {\n \"id\": \"schema/all-restrictions\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-restrictions\",\n \"expected\": {\n \"names\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ]\n }\n },\n {\n \"id\": \"graph/references-validation-equivalence\",\n \"operation\": \"semantic\",\n \"fixture\": \"graph\",\n \"expected\": {\n \"valid\": true,\n \"equivalent\": true\n }\n },\n {\n \"id\": \"tool/canonical-input\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"tool-input\",\n \"input\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ]\n },\n \"expected\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ],\n \"ignoreCase\": null\n }\n },\n {\n \"id\": \"constraints/rejected-value\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-u32\",\n \"input\": 11,\n \"expected\": {\n \"kind\": \"constraint-violation\"\n }\n },\n {\n \"id\": \"compatibility/result\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"result\",\n \"input\": {\n \"ok\": \"ready\"\n },\n \"expected\": {\n \"ok\": \"ready\"\n }\n },\n {\n \"id\": \"compatibility/custom-error\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"custom-error\",\n \"input\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n },\n \"expected\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n }\n }\n ]\n}\n" diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt new file mode 100644 index 0000000000..67505aba1c --- /dev/null +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt @@ -0,0 +1,291 @@ +///| +fn corpus_object(value : Json) -> Map[String, Json] raise { + match value { + Object(fields) => fields + _ => fail("expected conformance JSON object") + } +} + +///| +fn corpus_array(value : Json) -> Array[Json] raise { + match value { + Array(values) => values + _ => fail("expected conformance JSON array") + } +} + +///| +fn corpus_string(value : Json) -> String raise { + match value { + String(value) => value + _ => fail("expected conformance JSON string") + } +} + +///| +fn corpus_count(value : Json) -> Int raise { + match value { + Number(value, ..) => value.to_int() + _ => fail("expected conformance JSON number") + } +} + +///| +fn corpus_field( + name : String, + body : @model.SchemaType, +) -> @model.NamedFieldType { + { name, body, metadata: @model.empty_metadata(), } +} + +///| +fn conformance_fixture(name : String) -> SchemaRef raise { + let root = match name { + "s64" => @model.schema_type(S64(None)) + "u64" => @model.schema_type(U64(None)) + "duration" => @model.schema_type(Duration) + "quantity" => + @model.schema_type( + Quantity({ base_unit: "m", allowed_suffixes: [], min: None, max: None, }), + ) + "tool-input" => + @model.schema_type( + Record([ + corpus_field("pattern", @model.schema_type(String)), + corpus_field( + "paths", + @model.schema_type(List(@model.schema_type(String))), + ), + corpus_field( + "ignoreCase", + @model.schema_type(Option(@model.schema_type(Bool))), + ), + ]), + ) + "constrained-u32" => + @model.schema_type( + U32(Some({ min: None, max: Some(@types.Unsigned(10UL)), unit: None, })), + ) + "result" => + @model.schema_type( + Result( + Some(@model.schema_type(String)), + Some(@model.schema_type(U32(None))), + ), + ) + "custom-error" => + @model.schema_type( + Result( + Some(@model.schema_type(String)), + Some( + @model.schema_type( + Record([ + corpus_field("code", @model.schema_type(String)), + corpus_field("retryable", @model.schema_type(Bool)), + ]), + ), + ), + ), + ) + "optional-record" => { + let optional = @model.schema_type(Option(@model.schema_type(String))) + return SchemaRef::new({ + defs: [{ id: "conformance.optional", name: None, body: optional, }], + root: @model.schema_type( + Record([ + corpus_field("direct", optional), + corpus_field( + "referenced", + @model.schema_type(Ref("conformance.optional")), + ), + ]), + ), + }) + } + other => fail("unknown conformance fixture " + other) + } + SchemaRef::new({ defs: [], root, }) +} + +///| +fn conformance_at_pointer(value : Json, pointer : String) -> Json raise { + match pointer { + "" => value + "/required" => corpus_object(value).get("required").unwrap() + "/properties/nanoseconds" => + corpus_object(corpus_object(value).get("properties").unwrap()) + .get("nanoseconds") + .unwrap() + "/properties/mantissa" => + corpus_object(corpus_object(value).get("properties").unwrap()) + .get("mantissa") + .unwrap() + other => fail("unsupported conformance JSON pointer " + other) + } +} + +///| +fn assert_conformance_subset(actual : Json, expected : Json) -> Unit raise { + match expected { + Object(expected_fields) => { + let actual_fields = corpus_object(actual) + for entry in expected_fields { + let (name, expected_value) = entry + let actual_value = match actual_fields.get(name) { + Some(value) => value + None => fail("missing conformance JSON field " + name) + } + assert_conformance_subset(actual_value, expected_value) + } + } + _ => assert_eq(actual, expected) + } +} + +///| +fn assert_conformance_semantic( + name : String, + expected : Json, + corpus : Json, +) -> Unit raise { + match name { + "unsupported-leaves" => { + let types = [ + @model.SchemaTypeBody::Secret({ + inner: @model.schema_type(String), + category: None, + }), + @model.SchemaTypeBody::QuotaToken({ resource_name: None, }), + @model.SchemaTypeBody::PermissionCard({ polymorphic: false, }), + @model.SchemaTypeBody::Future(None), + @model.SchemaTypeBody::Stream(None), + ] + let expected_fields = corpus_object(expected) + assert_eq( + types.length(), + corpus_count(expected_fields.get("count").unwrap()), + ) + for body in types { + assert_conformance_subset( + test_schema(body).to_json_schema(include_draft_marker=false), + expected_fields.get("schema").unwrap(), + ) + } + } + "all-kinds" => { + let corpus_fields = corpus_object(corpus) + let supported = [ + "ref", "bool", "s8", "s16", "s32", "s64", "u8", "u16", "u32", "u64", "f32", + "f64", "char", "string", "record", "variant", "enum", "flags", "tuple", "list", + "fixed-list", "map", "option", "result", "text", "binary", "path", "url", + "datetime", "duration", "quantity", "union", "secret", "quota-token", "permission-card", + "future", "stream", + ] + let expected_names = corpus_array( + corpus_object(expected).get("names").unwrap(), + ).map(corpus_string) + assert_eq(supported, expected_names) + assert_eq( + corpus_array(corpus_fields.get("schemaKinds").unwrap()).map( + corpus_string, + ), + expected_names, + ) + } + "all-restrictions" => { + let corpus_fields = corpus_object(corpus) + let supported = [ + "numeric-minimum", "numeric-maximum", "numeric-unit", "text-languages", "text-min-length", + "text-max-length", "text-regex", "binary-mime-types", "binary-min-bytes", + "binary-max-bytes", "path-direction", "path-kind", "path-mime-types", "path-extensions", + "url-schemes", "url-hosts", "quantity-base-unit", "quantity-suffixes", "quantity-minimum", + "quantity-maximum", "union-prefix", "union-suffix", "union-regex", "union-field", + ] + let expected_names = corpus_array( + corpus_object(expected).get("names").unwrap(), + ).map(corpus_string) + assert_eq(supported, expected_names) + assert_eq( + corpus_array(corpus_fields.get("restrictionKinds").unwrap()).map( + corpus_string, + ), + expected_names, + ) + } + "graph" => { + let inline = test_schema( + Record([ + corpus_field( + "direct", + @model.schema_type(Option(@model.schema_type(String))), + ), + corpus_field( + "referenced", + @model.schema_type(Option(@model.schema_type(String))), + ), + ]), + ) + assert_eq( + conformance_fixture("optional-record").pack_json(Json::object({})), + inline.pack_json(Json::object({})), + ) + } + other => fail("unknown semantic conformance fixture " + other) + } +} + +///| +test "reflection conformance corpus executes the complete case-ID set" { + let corpus = @json.parse(reflection_conformance_corpus_json) + let root = corpus_object(corpus) + assert_eq(corpus_string(root.get("version").unwrap()), "1.0.0") + let executed : Array[String] = [] + for test_case_json in corpus_array(root.get("cases").unwrap()) { + let test_case = corpus_object(test_case_json) + let id = corpus_string(test_case.get("id").unwrap()) + guard !executed.any(value => value == id) else { + fail("duplicate conformance case ID " + id) + } + executed.push(id) + let name = corpus_string(test_case.get("fixture").unwrap()) + match corpus_string(test_case.get("operation").unwrap()) { + "roundtrip" => { + let schema = conformance_fixture(name) + let packed = schema.pack_json(test_case.get("input").unwrap()) + assert_eq( + schema.unpack_json(packed), + test_case.get("expected").unwrap(), + ) + } + "reject" => + try + conformance_fixture(name).pack_json(test_case.get("input").unwrap()) + catch { + _ => () + } noraise { + _ => fail("conformance case was accepted: " + id) + } + "json-schema" => + assert_conformance_subset( + conformance_at_pointer( + conformance_fixture(name).to_json_schema(include_draft_marker=false), + corpus_string(test_case.get("path").unwrap()), + ), + test_case.get("expected").unwrap(), + ) + "semantic" => + assert_conformance_semantic( + name, + test_case.get("expected").unwrap(), + corpus, + ) + operation => + fail("unknown conformance operation " + operation + " for " + id) + } + } + let declared = corpus_array(root.get("caseIds").unwrap()).map(corpus_string) + assert_eq(executed.length(), declared.length()) + for id in declared { + assert_true(executed.any(value => value == id)) + } +} diff --git a/sdks/rust/golem-rust/src/agentic/reflection.rs b/sdks/rust/golem-rust/src/agentic/reflection.rs index c39632ba62..3e395bd10e 100644 --- a/sdks/rust/golem-rust/src/agentic/reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/reflection.rs @@ -23,7 +23,7 @@ pub use super::tool_reflection::{ use crate::bindings::golem::agent::{common as wire_common, host}; use crate::schema::render::{ - RenderError, from_json_value, to_json_schema_with_config, to_json_value, + RenderError, from_json_value, to_json_value, to_reflection_json_schema, }; use crate::schema::validation::validate_value; use crate::schema::{ @@ -107,12 +107,7 @@ impl SchemaRef { #[cfg(feature = "json")] pub fn to_json_schema(&self, include_draft_marker: bool) -> serde_json::Value { - let config = if include_draft_marker { - crate::schema::render::JsonSchemaConfig::CANONICAL - } else { - crate::schema::render::JsonSchemaConfig::WITHOUT_DRAFT_MARKER - }; - to_json_schema_with_config(&self.graph, &self.root, config) + to_reflection_json_schema(&self.graph, &self.root, include_draft_marker) } } @@ -1683,11 +1678,15 @@ mod tests { ReflectedTransport, SchemaRef, TestReflectedTransport, validate_declared_output, }; use crate::bindings::golem::agent::common as wire_common; + use crate::schema::schema_type::{NumericBound, NumericRestrictions}; + use crate::schema::validation::{is_equivalent_cross_graph, validate_graph}; use crate::schema::{ - MetadataEnvelope, NamedFieldType, SchemaGraph, SchemaType, SchemaValue, VariantCaseType, + MetadataEnvelope, NamedFieldType, PermissionCardSpec, QuantitySpec, QuotaTokenSpec, + ResultSpec, SchemaGraph, SchemaType, SchemaTypeDef, SchemaValue, TypeId, VariantCaseType, VariantValuePayload, }; - use serde_json::json; + use serde_json::{Value, json}; + use std::collections::HashSet; use std::rc::Rc; use test_r::test; @@ -1725,6 +1724,283 @@ mod tests { json!({ "name": "demo", "enabled": true }) ); assert_eq!(schema.to_json_schema(true)["type"], json!("object")); + assert_eq!( + schema + .pack_json(&json!({ "name": "demo" })) + .expect("decode omitted option"), + SchemaValue::Record { + fields: vec![ + SchemaValue::String("demo".to_string()), + SchemaValue::Option { inner: None }, + ] + } + ); + assert_eq!(schema.to_json_schema(false)["required"], json!(["name"])); + } + + #[test] + fn reflection_json_schema_rejects_unrepresentable_leaves() { + for ty in [ + SchemaType::secret(Default::default()), + SchemaType::future(None), + SchemaType::stream(None), + ] { + let schema = SchemaRef::new(SchemaGraph::anonymous(ty)); + assert_eq!(schema.to_json_schema(false)["not"], json!({})); + } + } + + fn conformance_field(name: &str, body: SchemaType) -> NamedFieldType { + NamedFieldType { + name: name.to_string(), + body, + metadata: MetadataEnvelope::default(), + } + } + + fn conformance_schema(name: &str) -> SchemaRef { + let root = match name { + "s64" => SchemaType::s64(), + "u64" => SchemaType::u64(), + "duration" => SchemaType::duration(), + "quantity" => SchemaType::quantity(QuantitySpec { + base_unit: "m".to_string(), + allowed_suffixes: vec![], + min: None, + max: None, + }), + "tool-input" => SchemaType::record(vec![ + conformance_field("pattern", SchemaType::string()), + conformance_field("paths", SchemaType::list(SchemaType::string())), + conformance_field("ignoreCase", SchemaType::option(SchemaType::bool())), + ]), + "constrained-u32" => SchemaType::U32 { + restrictions: Some(NumericRestrictions { + min: None, + max: Some(NumericBound::Unsigned(10)), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + "result" => SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: Some(Box::new(SchemaType::u32())), + }), + "custom-error" => SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: Some(Box::new(SchemaType::record(vec![ + conformance_field("code", SchemaType::string()), + conformance_field("retryable", SchemaType::bool()), + ]))), + }), + "optional-record" => { + let id = TypeId::new("conformance.optional"); + return SchemaRef::new(SchemaGraph { + defs: vec![SchemaTypeDef { + id: id.clone(), + name: None, + body: SchemaType::option(SchemaType::string()), + }], + root: SchemaType::record(vec![ + conformance_field("direct", SchemaType::option(SchemaType::string())), + conformance_field("referenced", SchemaType::ref_to(id)), + ]), + }); + } + other => panic!("unknown conformance fixture {other}"), + }; + SchemaRef::new(SchemaGraph::anonymous(root)) + } + + fn conformance_json_pointer<'a>(value: &'a Value, pointer: &str) -> &'a Value { + if pointer.is_empty() { + value + } else { + value + .pointer(pointer) + .unwrap_or_else(|| panic!("missing JSON pointer {pointer} in {value}")) + } + } + + fn assert_conformance_subset(actual: &Value, expected: &Value) { + match expected { + Value::Object(expected) => { + let actual = actual.as_object().expect("actual JSON object"); + for (key, expected) in expected { + assert_conformance_subset( + actual + .get(key) + .unwrap_or_else(|| panic!("missing key {key} in {actual:?}")), + expected, + ); + } + } + _ => assert_eq!(actual, expected), + } + } + + fn assert_conformance_semantic(fixture: &str, expected: &Value) { + match fixture { + "unsupported-leaves" => { + let types = [ + SchemaType::secret(Default::default()), + SchemaType::quota_token(QuotaTokenSpec::default()), + SchemaType::permission_card(PermissionCardSpec::default()), + SchemaType::future(None), + SchemaType::stream(None), + ]; + assert_eq!(types.len(), expected["count"].as_u64().unwrap() as usize); + for ty in types { + assert_conformance_subset( + &SchemaRef::new(SchemaGraph::anonymous(ty)).to_json_schema(false), + &expected["schema"], + ); + } + } + "all-kinds" => assert_eq!( + serde_json::to_value(vec![ + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream", + ]) + .unwrap(), + expected["names"] + ), + "all-restrictions" => assert_eq!( + serde_json::to_value(vec![ + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field", + ]) + .unwrap(), + expected["names"] + ), + "graph" => { + let referenced = conformance_schema("optional-record"); + let inline = SchemaRef::new(SchemaGraph::anonymous(SchemaType::record(vec![ + conformance_field("direct", SchemaType::option(SchemaType::string())), + conformance_field("referenced", SchemaType::option(SchemaType::string())), + ]))); + assert!(validate_graph(referenced.graph()).is_ok()); + assert!(is_equivalent_cross_graph( + referenced.graph(), + referenced.root(), + inline.graph(), + inline.root(), + )); + } + other => panic!("unknown semantic conformance fixture {other}"), + } + } + + #[test] + fn reflection_conformance_corpus() { + let corpus: Value = serde_json::from_str(include_str!( + "../../../../../test-data/reflection-conformance/v1.json" + )) + .expect("valid reflection conformance corpus"); + let cases = corpus["cases"].as_array().expect("cases array"); + let mut executed = HashSet::new(); + for case in cases { + let id = case["id"].as_str().expect("case id"); + assert!(executed.insert(id), "duplicate case ID {id}"); + let fixture = case["fixture"].as_str().expect("fixture"); + let expected = &case["expected"]; + match case["operation"].as_str().expect("operation") { + "roundtrip" => { + let schema = conformance_schema(fixture); + let packed = schema + .pack_json(&case["input"]) + .unwrap_or_else(|error| panic!("{id}: {error}")); + assert_eq!( + schema + .unpack_json(&packed) + .unwrap_or_else(|error| panic!("{id}: {error}")), + *expected, + "{id}", + ); + } + "reject" => { + let schema = conformance_schema(fixture); + assert!( + schema.pack_json(&case["input"]).is_err(), + "{id} was accepted" + ); + } + "json-schema" => { + let schema = conformance_schema(fixture); + let rendered = schema.to_json_schema(false); + assert_conformance_subset( + conformance_json_pointer(&rendered, case["path"].as_str().expect("path")), + expected, + ); + } + "semantic" => assert_conformance_semantic(fixture, expected), + operation => panic!("unknown conformance operation {operation} for {id}"), + } + } + let declared: HashSet<_> = corpus["caseIds"] + .as_array() + .expect("declared case IDs") + .iter() + .map(|id| id.as_str().expect("declared case ID")) + .collect(); + assert_eq!(executed, declared, "missing or unknown conformance cases"); } #[test] diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala new file mode 100644 index 0000000000..3af48619f5 --- /dev/null +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala @@ -0,0 +1,485 @@ +/* + * Copyright 2024-2026 Golem Cloud + * + * Licensed under the Golem Source License v1.1 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://license.golem.cloud/LICENSE + */ + +package golem.reflection + +// Generated by scripts/sync-reflection-conformance.mjs. Do not edit. +private[reflection] object ReflectionConformanceCorpus { + val json: String = """{ + "version": "1.0.0", + "schemaKinds": [ + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream" + ], + "restrictionKinds": [ + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field" + ], + "caseIds": [ + "canonical/s64-min", + "canonical/s64-max", + "canonical/u64-max", + "canonical/duration-min", + "canonical/duration-max", + "canonical/quantity-wide-mantissa", + "canonical/omitted-options", + "errors/s64-plus", + "errors/s64-leading-zero", + "errors/s64-negative-zero", + "errors/s64-overflow", + "errors/u64-negative", + "errors/u64-overflow", + "errors/wide-wrong-json-type", + "errors/duration-legacy-string", + "errors/quantity-negative-zero", + "json-schema/s64", + "json-schema/u64", + "json-schema/duration-nanoseconds", + "json-schema/quantity-mantissa", + "json-schema/optional-required", + "json-schema/unsupported-leaves", + "schema/all-kinds", + "schema/all-restrictions", + "graph/references-validation-equivalence", + "tool/canonical-input", + "constraints/rejected-value", + "compatibility/result", + "compatibility/custom-error" + ], + "cases": [ + { + "id": "canonical/s64-min", + "operation": "roundtrip", + "fixture": "s64", + "input": "-9223372036854775808", + "expected": "-9223372036854775808" + }, + { + "id": "canonical/s64-max", + "operation": "roundtrip", + "fixture": "s64", + "input": "9223372036854775807", + "expected": "9223372036854775807" + }, + { + "id": "canonical/u64-max", + "operation": "roundtrip", + "fixture": "u64", + "input": "18446744073709551615", + "expected": "18446744073709551615" + }, + { + "id": "canonical/duration-min", + "operation": "roundtrip", + "fixture": "duration", + "input": { + "nanoseconds": "-9223372036854775808" + }, + "expected": { + "nanoseconds": "-9223372036854775808" + } + }, + { + "id": "canonical/duration-max", + "operation": "roundtrip", + "fixture": "duration", + "input": { + "nanoseconds": "9223372036854775807" + }, + "expected": { + "nanoseconds": "9223372036854775807" + } + }, + { + "id": "canonical/quantity-wide-mantissa", + "operation": "roundtrip", + "fixture": "quantity", + "input": { + "mantissa": "-9223372036854775808", + "scale": -9, + "unit": "m" + }, + "expected": { + "mantissa": "-9223372036854775808", + "scale": -9, + "unit": "m" + } + }, + { + "id": "canonical/omitted-options", + "operation": "roundtrip", + "fixture": "optional-record", + "input": {}, + "expected": { + "direct": null, + "referenced": null + } + }, + { + "id": "errors/s64-plus", + "operation": "reject", + "fixture": "s64", + "input": "+1", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/s64-leading-zero", + "operation": "reject", + "fixture": "s64", + "input": "01", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/s64-negative-zero", + "operation": "reject", + "fixture": "s64", + "input": "-0", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/s64-overflow", + "operation": "reject", + "fixture": "s64", + "input": "9223372036854775808", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/u64-negative", + "operation": "reject", + "fixture": "u64", + "input": "-1", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/u64-overflow", + "operation": "reject", + "fixture": "u64", + "input": "18446744073709551616", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/wide-wrong-json-type", + "operation": "reject", + "fixture": "s64", + "input": 1, + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/duration-legacy-string", + "operation": "reject", + "fixture": "duration", + "input": "PT1S", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/quantity-negative-zero", + "operation": "reject", + "fixture": "quantity", + "input": { + "mantissa": "-0", + "scale": 0, + "unit": "m" + }, + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "json-schema/s64", + "operation": "json-schema", + "fixture": "s64", + "path": "", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/u64", + "operation": "json-schema", + "fixture": "u64", + "path": "", + "expected": { + "type": "string", + "format": "uint64", + "pattern": "^(?:0|[1-9][0-9]*)$", + "x-golem-minimum": "0", + "x-golem-maximum": "18446744073709551615" + } + }, + { + "id": "json-schema/duration-nanoseconds", + "operation": "json-schema", + "fixture": "duration", + "path": "/properties/nanoseconds", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/quantity-mantissa", + "operation": "json-schema", + "fixture": "quantity", + "path": "/properties/mantissa", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/optional-required", + "operation": "json-schema", + "fixture": "optional-record", + "path": "/required", + "expected": [] + }, + { + "id": "json-schema/unsupported-leaves", + "operation": "semantic", + "fixture": "unsupported-leaves", + "expected": { + "count": 5, + "schema": { + "not": {} + } + } + }, + { + "id": "schema/all-kinds", + "operation": "semantic", + "fixture": "all-kinds", + "expected": { + "names": [ + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream" + ] + } + }, + { + "id": "schema/all-restrictions", + "operation": "semantic", + "fixture": "all-restrictions", + "expected": { + "names": [ + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field" + ] + } + }, + { + "id": "graph/references-validation-equivalence", + "operation": "semantic", + "fixture": "graph", + "expected": { + "valid": true, + "equivalent": true + } + }, + { + "id": "tool/canonical-input", + "operation": "roundtrip", + "fixture": "tool-input", + "input": { + "pattern": "*.rs", + "paths": [ + "src", + "tests" + ] + }, + "expected": { + "pattern": "*.rs", + "paths": [ + "src", + "tests" + ], + "ignoreCase": null + } + }, + { + "id": "constraints/rejected-value", + "operation": "reject", + "fixture": "constrained-u32", + "input": 11, + "expected": { + "kind": "constraint-violation" + } + }, + { + "id": "compatibility/result", + "operation": "roundtrip", + "fixture": "result", + "input": { + "ok": "ready" + }, + "expected": { + "ok": "ready" + } + }, + { + "id": "compatibility/custom-error", + "operation": "roundtrip", + "fixture": "custom-error", + "input": { + "err": { + "code": "denied", + "retryable": false + } + }, + "expected": { + "err": { + "code": "denied", + "retryable": false + } + } + } + ] +} +""" +} diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala new file mode 100644 index 0000000000..ccd829ab4b --- /dev/null +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala @@ -0,0 +1,264 @@ +/* + * Copyright 2024-2026 Golem Cloud + * + * Licensed under the Golem Source License v1.1 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://license.golem.cloud/LICENSE + */ + +package golem.reflection + +import golem.schema.* +import golem.schema.SchemaTypeBody.* +import zio.blocks.schema.json.Json +import zio.test.* + +import scala.collection.immutable.ListMap +import scala.collection.mutable + +object ReflectionConformanceSpec extends ZIOSpecDefault { + private def fields(value: Json): ListMap[String, Json] = value match { + case Json.Object(values) => ListMap(values.toList*) + case other => throw new AssertionError(s"expected JSON object, got $other") + } + + private def elements(value: Json): List[Json] = value match { + case Json.Array(values) => values.toList + case other => throw new AssertionError(s"expected JSON array, got $other") + } + + private def string(value: Json): String = value match { + case Json.String(value) => value + case other => throw new AssertionError(s"expected JSON string, got $other") + } + + private def count(value: Json): Int = value match { + case Json.Number(value) => value.toInt + case other => throw new AssertionError(s"expected JSON number, got $other") + } + + private def field(name: String, body: SchemaType): NamedFieldType = NamedFieldType(name, body) + + private def fixture(name: String): SchemaRef = { + val root = name match { + case "s64" => SchemaType(S64Type()) + case "u64" => SchemaType(U64Type()) + case "duration" => SchemaType(DurationType) + case "quantity" => SchemaType(QuantityType(QuantitySpec("m", Nil, None, None))) + case "tool-input" => + SchemaType( + RecordType( + List( + field("pattern", SchemaType(StringType)), + field("paths", SchemaType(ListType(SchemaType(StringType)))), + field("ignoreCase", SchemaType(OptionType(SchemaType(BoolType)))) + ) + ) + ) + case "constrained-u32" => + SchemaType(U32Type(Some(NumericRestrictions(max = Some(NumericBound.Unsigned(10)))))) + case "result" => + SchemaType(ResultType(Some(SchemaType(StringType)), Some(SchemaType(U32Type())))) + case "custom-error" => + SchemaType( + ResultType( + Some(SchemaType(StringType)), + Some( + SchemaType( + RecordType( + List( + field("code", SchemaType(StringType)), + field("retryable", SchemaType(BoolType)) + ) + ) + ) + ) + ) + ) + case "optional-record" => + val optional = SchemaType(OptionType(SchemaType(StringType))) + return SchemaRef( + SchemaGraph( + ListMap("conformance.optional" -> SchemaTypeDef(optional)), + SchemaType( + RecordType( + List( + field("direct", optional), + field("referenced", SchemaType(RefType("conformance.optional"))) + ) + ) + ) + ) + ) + case other => throw new AssertionError(s"unknown conformance fixture $other") + } + SchemaRef(SchemaGraph(ListMap.empty, root)) + } + + private def atPointer(value: Json, pointer: String): Json = + if (pointer.isEmpty) value + else + pointer + .stripPrefix("/") + .split('/') + .foldLeft(value) { (current, part) => + fields(current).getOrElse( + part.replace("~1", "/").replace("~0", "~"), + throw new AssertionError(s"missing JSON pointer $pointer in $value") + ) + } + + private def assertSubset(actual: Json, expected: Json): Unit = expected match { + case Json.Object(expectedFields) => + val actualFields = fields(actual) + expectedFields.toList.foreach { case (name, value) => + assertSubset( + actualFields.getOrElse(name, throw new AssertionError(s"missing $name in $actual")), + value + ) + } + case _ => Predef.assert(actual == expected, s"expected $expected, got $actual") + } + + private def assertSemantic(name: String, expected: Json, corpus: Json): Unit = name match { + case "unsupported-leaves" => + val unsupported = List[SchemaTypeBody]( + SecretType(SecretSpec(SchemaType(StringType), None)), + QuotaTokenType(QuotaTokenSpec(None)), + PermissionCardType(PermissionCardSpec(polymorphic = false)), + FutureType(None), + StreamType(None) + ) + Predef.assert(unsupported.size == count(fields(expected)("count"))) + unsupported.foreach { body => + assertSubset( + SchemaRef(SchemaGraph(ListMap.empty, SchemaType(body))).toJsonSchema(false), + fields(expected)("schema") + ) + } + case "all-kinds" => + val supported = List( + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream" + ) + val expectedNames = elements(fields(expected)("names")).map(string) + Predef.assert(supported == expectedNames) + Predef.assert(elements(fields(corpus)("schemaKinds")).map(string) == expectedNames) + case "all-restrictions" => + val supported = List( + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field" + ) + val expectedNames = elements(fields(expected)("names")).map(string) + Predef.assert(supported == expectedNames) + Predef.assert(elements(fields(corpus)("restrictionKinds")).map(string) == expectedNames) + case "graph" => + val inline = SchemaRef( + SchemaGraph( + ListMap.empty, + SchemaType( + RecordType( + List( + field("direct", SchemaType(OptionType(SchemaType(StringType)))), + field("referenced", SchemaType(OptionType(SchemaType(StringType)))) + ) + ) + ) + ) + ) + Predef.assert(fixture("optional-record").packJson(Json.Object()) == inline.packJson(Json.Object())) + case other => throw new AssertionError(s"unknown semantic conformance fixture $other") + } + + override def spec = suite("reflection conformance corpus")( + test("executes the complete declared case-ID set") { + val corpus = Json.parse(ReflectionConformanceCorpus.json).fold(throw _, identity) + val root = fields(corpus) + Predef.assert(string(root("version")) == "1.0.0") + val executed = mutable.Set.empty[String] + elements(root("cases")).foreach { testCaseJson => + val testCase = fields(testCaseJson) + val id = string(testCase("id")) + Predef.assert(executed.add(id), s"duplicate case ID $id") + val name = string(testCase("fixture")) + string(testCase("operation")) match { + case "roundtrip" => + val schema = fixture(name) + val packed = + schema.packJson(testCase("input")).fold(error => throw new AssertionError(s"$id: $error"), identity) + Predef.assert(schema.unpackJson(packed) == Right(testCase("expected")), id) + case "reject" => + Predef.assert(fixture(name).validateJson(testCase("input")).isLeft, s"$id was accepted") + case "json-schema" => + assertSubset( + atPointer(fixture(name).toJsonSchema(false), string(testCase("path"))), + testCase("expected") + ) + case "semantic" => assertSemantic(name, testCase("expected"), corpus) + case operation => throw new AssertionError(s"unknown conformance operation $operation for $id") + } + } + val declared = elements(root("caseIds")).map(string).toSet + assertTrue(executed.toSet == declared) + } + ) +} diff --git a/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts new file mode 100644 index 0000000000..f25c2f7c58 --- /dev/null +++ b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts @@ -0,0 +1,210 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + field, + schemaType, + t, + type SchemaGraph, + type SchemaType, +} from '../src/internal/schema-model'; +import { SchemaRef, type JsonValue } from '../src/schema/ref'; + +interface ConformanceCase { + id: string; + operation: 'roundtrip' | 'reject' | 'json-schema' | 'semantic'; + fixture: string; + input?: JsonValue; + path?: string; + expected: JsonValue; +} + +interface ConformanceCorpus { + version: string; + schemaKinds: string[]; + restrictionKinds: string[]; + caseIds: string[]; + cases: ConformanceCase[]; +} + +const corpus = JSON.parse( + readFileSync( + new URL('../../../../../test-data/reflection-conformance/v1.json', import.meta.url), + 'utf8', + ), +) as ConformanceCorpus; + +const supportedSchemaKinds = [ + 'ref', 'bool', 's8', 's16', 's32', 's64', 'u8', 'u16', 'u32', 'u64', + 'f32', 'f64', 'char', 'string', 'record', 'variant', 'enum', 'flags', 'tuple', + 'list', 'fixed-list', 'map', 'option', 'result', 'text', 'binary', 'path', 'url', + 'datetime', 'duration', 'quantity', 'union', 'secret', 'quota-token', + 'permission-card', 'future', 'stream', +] as const; + +const supportedRestrictionKinds = [ + 'numeric-minimum', 'numeric-maximum', 'numeric-unit', + 'text-languages', 'text-min-length', 'text-max-length', 'text-regex', + 'binary-mime-types', 'binary-min-bytes', 'binary-max-bytes', + 'path-direction', 'path-kind', 'path-mime-types', 'path-extensions', + 'url-schemes', 'url-hosts', 'quantity-base-unit', 'quantity-suffixes', + 'quantity-minimum', 'quantity-maximum', + 'union-prefix', 'union-suffix', 'union-regex', 'union-field', +] as const; + +function schema(root: SchemaType): SchemaRef { + return new SchemaRef({ defs: new Map(), root }); +} + +function fixture(name: string): SchemaRef { + switch (name) { + case 's64': + return schema(t.s64()); + case 'u64': + return schema(t.u64()); + case 'duration': + return schema(t.duration()); + case 'quantity': + return schema(t.quantity({ baseUnit: 'm', allowedUnits: [] })); + case 'optional-record': { + const graph: SchemaGraph = { + defs: new Map([['conformance.optional', { body: t.option(t.string()) }]]), + root: t.record([ + field('direct', t.option(t.string())), + field('referenced', t.ref('conformance.optional')), + ]), + }; + return new SchemaRef(graph); + } + case 'tool-input': + return schema( + t.record([ + field('pattern', t.string()), + field('paths', t.list(t.string())), + field('ignoreCase', t.option(t.bool())), + ]), + ); + case 'constrained-u32': + return schema(t.u32({ max: { tag: 'unsigned', val: 10n } })); + case 'result': + return schema(t.result(t.string(), t.u32())); + case 'custom-error': + return schema( + t.result(t.string(), t.record([field('code', t.string()), field('retryable', t.bool())])), + ); + default: + throw new Error(`unknown conformance fixture ${name}`); + } +} + +function atPointer(value: JsonValue, pointer: string): JsonValue { + if (pointer === '') return value; + return pointer + .slice(1) + .split('/') + .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~')) + .reduce((current, part) => { + if (current === null || typeof current !== 'object' || Array.isArray(current)) { + throw new Error(`cannot resolve ${pointer}`); + } + const next = current[part]; + if (next === undefined) throw new Error(`missing ${pointer}`); + return next; + }, value); +} + +function expectSubset(actual: JsonValue, expected: JsonValue): void { + if (expected !== null && typeof expected === 'object' && !Array.isArray(expected)) { + expect(actual).not.toBeNull(); + expect(Array.isArray(actual)).toBe(false); + for (const [key, value] of Object.entries(expected)) { + expectSubset((actual as Record)[key], value); + } + } else { + expect(actual).toEqual(expected); + } +} + +function assertSemantic(testCase: ConformanceCase): void { + const expected = testCase.expected as Record; + switch (testCase.fixture) { + case 'unsupported-leaves': { + const unsupported = [ + t.secret(t.string()), + t.quotaToken({}), + t.permissionCard({ polymorphic: false }), + schemaType({ tag: 'future', element: t.string() }), + t.stream(t.string()), + ]; + expect(unsupported).toHaveLength(expected.count as number); + for (const type of unsupported) expectSubset(schema(type).toJsonSchema(), expected.schema); + break; + } + case 'all-kinds': + expect(supportedSchemaKinds).toEqual(expected.names); + expect(corpus.schemaKinds).toEqual(expected.names); + break; + case 'all-restrictions': + expect(supportedRestrictionKinds).toEqual(expected.names); + expect(corpus.restrictionKinds).toEqual(expected.names); + break; + case 'graph': { + const referenced = fixture('optional-record'); + const inline = schema( + t.record([ + field('direct', t.option(t.string())), + field('referenced', t.option(t.string())), + ]), + ); + expect(referenced.packJson({})).toEqual(inline.packJson({})); + expect(referenced.validateJson({}).success).toBe(true); + break; + } + default: + throw new Error(`unknown semantic conformance fixture ${testCase.fixture}`); + } +} + +describe('reflection conformance corpus', () => { + it('executes the complete declared case-ID set', () => { + expect(corpus.version).toBe('1.0.0'); + const executed = new Set(); + for (const testCase of corpus.cases) { + expect(executed.has(testCase.id), `duplicate case ID ${testCase.id}`).toBe(false); + executed.add(testCase.id); + switch (testCase.operation) { + case 'roundtrip': { + const ref = fixture(testCase.fixture); + expect(ref.unpackJson(ref.packJson(testCase.input!)), testCase.id).toEqual( + testCase.expected, + ); + break; + } + case 'reject': { + const ref = fixture(testCase.fixture); + expect(ref.validateJson(testCase.input!).success, testCase.id).toBe(false); + break; + } + case 'json-schema': + expectSubset( + atPointer(fixture(testCase.fixture).toJsonSchema(), testCase.path ?? ''), + testCase.expected, + ); + break; + case 'semantic': + assertSemantic(testCase); + break; + default: + throw new Error(`unknown conformance operation for ${testCase.id}`); + } + } + expect([...executed].sort()).toEqual([...corpus.caseIds].sort()); + }); +}); diff --git a/test-data/reflection-conformance/v1.json b/test-data/reflection-conformance/v1.json new file mode 100644 index 0000000000..45e8e83f11 --- /dev/null +++ b/test-data/reflection-conformance/v1.json @@ -0,0 +1,293 @@ +{ + "version": "1.0.0", + "schemaKinds": [ + "ref", "bool", "s8", "s16", "s32", "s64", "u8", "u16", "u32", "u64", + "f32", "f64", "char", "string", "record", "variant", "enum", "flags", "tuple", + "list", "fixed-list", "map", "option", "result", "text", "binary", "path", "url", + "datetime", "duration", "quantity", "union", "secret", "quota-token", + "permission-card", "future", "stream" + ], + "restrictionKinds": [ + "numeric-minimum", "numeric-maximum", "numeric-unit", + "text-languages", "text-min-length", "text-max-length", "text-regex", + "binary-mime-types", "binary-min-bytes", "binary-max-bytes", + "path-direction", "path-kind", "path-mime-types", "path-extensions", + "url-schemes", "url-hosts", "quantity-base-unit", "quantity-suffixes", + "quantity-minimum", "quantity-maximum", + "union-prefix", "union-suffix", "union-regex", "union-field" + ], + "caseIds": [ + "canonical/s64-min", + "canonical/s64-max", + "canonical/u64-max", + "canonical/duration-min", + "canonical/duration-max", + "canonical/quantity-wide-mantissa", + "canonical/omitted-options", + "errors/s64-plus", + "errors/s64-leading-zero", + "errors/s64-negative-zero", + "errors/s64-overflow", + "errors/u64-negative", + "errors/u64-overflow", + "errors/wide-wrong-json-type", + "errors/duration-legacy-string", + "errors/quantity-negative-zero", + "json-schema/s64", + "json-schema/u64", + "json-schema/duration-nanoseconds", + "json-schema/quantity-mantissa", + "json-schema/optional-required", + "json-schema/unsupported-leaves", + "schema/all-kinds", + "schema/all-restrictions", + "graph/references-validation-equivalence", + "tool/canonical-input", + "constraints/rejected-value", + "compatibility/result", + "compatibility/custom-error" + ], + "cases": [ + { + "id": "canonical/s64-min", + "operation": "roundtrip", + "fixture": "s64", + "input": "-9223372036854775808", + "expected": "-9223372036854775808" + }, + { + "id": "canonical/s64-max", + "operation": "roundtrip", + "fixture": "s64", + "input": "9223372036854775807", + "expected": "9223372036854775807" + }, + { + "id": "canonical/u64-max", + "operation": "roundtrip", + "fixture": "u64", + "input": "18446744073709551615", + "expected": "18446744073709551615" + }, + { + "id": "canonical/duration-min", + "operation": "roundtrip", + "fixture": "duration", + "input": { "nanoseconds": "-9223372036854775808" }, + "expected": { "nanoseconds": "-9223372036854775808" } + }, + { + "id": "canonical/duration-max", + "operation": "roundtrip", + "fixture": "duration", + "input": { "nanoseconds": "9223372036854775807" }, + "expected": { "nanoseconds": "9223372036854775807" } + }, + { + "id": "canonical/quantity-wide-mantissa", + "operation": "roundtrip", + "fixture": "quantity", + "input": { "mantissa": "-9223372036854775808", "scale": -9, "unit": "m" }, + "expected": { "mantissa": "-9223372036854775808", "scale": -9, "unit": "m" } + }, + { + "id": "canonical/omitted-options", + "operation": "roundtrip", + "fixture": "optional-record", + "input": {}, + "expected": { "direct": null, "referenced": null } + }, + { + "id": "errors/s64-plus", + "operation": "reject", + "fixture": "s64", + "input": "+1", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/s64-leading-zero", + "operation": "reject", + "fixture": "s64", + "input": "01", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/s64-negative-zero", + "operation": "reject", + "fixture": "s64", + "input": "-0", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/s64-overflow", + "operation": "reject", + "fixture": "s64", + "input": "9223372036854775808", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/u64-negative", + "operation": "reject", + "fixture": "u64", + "input": "-1", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/u64-overflow", + "operation": "reject", + "fixture": "u64", + "input": "18446744073709551616", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/wide-wrong-json-type", + "operation": "reject", + "fixture": "s64", + "input": 1, + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/duration-legacy-string", + "operation": "reject", + "fixture": "duration", + "input": "PT1S", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/quantity-negative-zero", + "operation": "reject", + "fixture": "quantity", + "input": { "mantissa": "-0", "scale": 0, "unit": "m" }, + "expected": { "kind": "invalid-json" } + }, + { + "id": "json-schema/s64", + "operation": "json-schema", + "fixture": "s64", + "path": "", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/u64", + "operation": "json-schema", + "fixture": "u64", + "path": "", + "expected": { + "type": "string", + "format": "uint64", + "pattern": "^(?:0|[1-9][0-9]*)$", + "x-golem-minimum": "0", + "x-golem-maximum": "18446744073709551615" + } + }, + { + "id": "json-schema/duration-nanoseconds", + "operation": "json-schema", + "fixture": "duration", + "path": "/properties/nanoseconds", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/quantity-mantissa", + "operation": "json-schema", + "fixture": "quantity", + "path": "/properties/mantissa", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/optional-required", + "operation": "json-schema", + "fixture": "optional-record", + "path": "/required", + "expected": [] + }, + { + "id": "json-schema/unsupported-leaves", + "operation": "semantic", + "fixture": "unsupported-leaves", + "expected": { "count": 5, "schema": { "not": {} } } + }, + { + "id": "schema/all-kinds", + "operation": "semantic", + "fixture": "all-kinds", + "expected": { + "names": [ + "ref", "bool", "s8", "s16", "s32", "s64", "u8", "u16", "u32", "u64", + "f32", "f64", "char", "string", "record", "variant", "enum", "flags", "tuple", + "list", "fixed-list", "map", "option", "result", "text", "binary", "path", "url", + "datetime", "duration", "quantity", "union", "secret", "quota-token", + "permission-card", "future", "stream" + ] + } + }, + { + "id": "schema/all-restrictions", + "operation": "semantic", + "fixture": "all-restrictions", + "expected": { + "names": [ + "numeric-minimum", "numeric-maximum", "numeric-unit", + "text-languages", "text-min-length", "text-max-length", "text-regex", + "binary-mime-types", "binary-min-bytes", "binary-max-bytes", + "path-direction", "path-kind", "path-mime-types", "path-extensions", + "url-schemes", "url-hosts", "quantity-base-unit", "quantity-suffixes", + "quantity-minimum", "quantity-maximum", + "union-prefix", "union-suffix", "union-regex", "union-field" + ] + } + }, + { + "id": "graph/references-validation-equivalence", + "operation": "semantic", + "fixture": "graph", + "expected": { "valid": true, "equivalent": true } + }, + { + "id": "tool/canonical-input", + "operation": "roundtrip", + "fixture": "tool-input", + "input": { "pattern": "*.rs", "paths": ["src", "tests"] }, + "expected": { "pattern": "*.rs", "paths": ["src", "tests"], "ignoreCase": null } + }, + { + "id": "constraints/rejected-value", + "operation": "reject", + "fixture": "constrained-u32", + "input": 11, + "expected": { "kind": "constraint-violation" } + }, + { + "id": "compatibility/result", + "operation": "roundtrip", + "fixture": "result", + "input": { "ok": "ready" }, + "expected": { "ok": "ready" } + }, + { + "id": "compatibility/custom-error", + "operation": "roundtrip", + "fixture": "custom-error", + "input": { "err": { "code": "denied", "retryable": false } }, + "expected": { "err": { "code": "denied", "retryable": false } } + } + ] +} From 524b3daba188b206b139ede727fa29b281ff2877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 14:43:48 +0200 Subject: [PATCH 13/37] docs: align reflection JSON semantics --- .../content/next/concepts/agent-reflection.mdx | 17 +++++++++++++++-- .../common/golem-agent-reflection.mdx | 4 ++-- .../effect/golem-agent-reflection-effect.mdx | 8 +++++--- .../moonbit/golem-agent-reflection-moonbit.mdx | 8 +++++++- .../rust/golem-agent-reflection-rust.mdx | 11 ++++++++--- .../scala/golem-agent-reflection-scala.mdx | 7 +++++++ .../ts/golem-agent-reflection-ts.mdx | 7 ++++--- .../common/golem-agent-reflection/SKILL.md | 4 ++-- .../golem-agent-reflection-effect/SKILL.md | 8 +++++--- .../golem-agent-reflection-moonbit/SKILL.md | 8 +++++++- .../rust/golem-agent-reflection-rust/SKILL.md | 11 ++++++++--- .../scala/golem-agent-reflection-scala/SKILL.md | 7 +++++++ .../ts/golem-agent-reflection-ts/SKILL.md | 7 ++++--- 13 files changed, 81 insertions(+), 26 deletions(-) diff --git a/docs/src/content/next/concepts/agent-reflection.mdx b/docs/src/content/next/concepts/agent-reflection.mdx index 9924c5bcb2..716e86da8d 100644 --- a/docs/src/content/next/concepts/agent-reflection.mdx +++ b/docs/src/content/next/concepts/agent-reflection.mdx @@ -39,8 +39,21 @@ Static and discovered clients validate what they know before opening RPC. Discovered schemas include numeric and text restrictions, nested structures, defaults, and tool command constraints. Reflected JSON helpers provide canonical JSON conversion, and JSON Schema projections make the same shape -available to JSON-based integrations. Streams and opaque capabilities cannot -be represented as JSON and must use schema-native value APIs. +available to JSON-based integrations. An omitted optional record field and an +explicit `null` both decode as absent; re-encoding may include the field with +`null`, and optional fields are omitted from JSON Schema `required`. + +Canonical JSON uses numbers for integers through 32 bits. Signed and unsigned +64-bit integers are canonical decimal strings. Duration is +`{ "nanoseconds": "..." }`, and a quantity's `mantissa` is also a signed decimal +string. A leading `+`, leading zeroes, `-0`, the wrong JSON type, and values +outside the declared integer range are rejected. Reflection JSON Schema uses +matching string patterns and exact string-valued bounds. + +Streams, futures, and opaque capabilities cannot be represented as reflected +JSON and must use schema-native value APIs. Their reflection-specific JSON +Schema projection is unsatisfiable; other host JSON Schema projections retain +their existing policies. The host remains authoritative for visibility, authorization, effective configuration, durable identity resolution, and the deployed target schema. diff --git a/docs/src/content/next/how-to-guides/common/golem-agent-reflection.mdx b/docs/src/content/next/how-to-guides/common/golem-agent-reflection.mdx index d04887fc2c..1015397cf0 100644 --- a/docs/src/content/next/how-to-guides/common/golem-agent-reflection.mdx +++ b/docs/src/content/next/how-to-guides/common/golem-agent-reflection.mdx @@ -33,11 +33,11 @@ Creation-time configuration overrides are optional because component defaults ma ## Optional values and canonical JSON -Normal RPC and caller-defined static clients use the language's normal optional-field syntax. Reflected JSON is schema-shaped instead: records contain every declared field and an absent `option` is `null`. Tool defaults do not turn `Present` into a key-existence check; constraints evaluate the effective optional/default carrier, and nested `ValueIs` comparisons use the declared nested schema. +Normal RPC and caller-defined static clients use the language's normal optional-field syntax. In reflected JSON, an omitted `option` record field and an explicit `null` both decode as absent; re-encoding may include the field with `null`. Reflection JSON Schema therefore omits optional fields from `required`. Tool defaults do not turn `Present` into a key-existence check; constraints evaluate the effective optional/default carrier, and nested `ValueIs` comparisons use the declared nested schema. Canonical invocation JSON uses JSON numbers for integers through 32 bits. Signed and unsigned 64-bit integers are canonical base-10 strings, as are duration nanoseconds and quantity mantissas. A duration is `{ "nanoseconds": "..." }`; a quantity is `{ "mantissa": "...", "scale": number, "unit": string }`. Leading zeroes, `+`, and `-0` are invalid. JSON Schema projections use the same string patterns and carry the exact range as metadata. -Streams and opaque capabilities have no canonical JSON representation. Use the schema-native `*Value` APIs, transfer each owned handle once, consume returned streams, and cancel or close started operations according to the language-specific API. +Streams, futures, and opaque capabilities have no canonical JSON representation. Their reflection JSON Schema projection is unsatisfiable. Use the schema-native `*Value` APIs, transfer each owned handle once, consume returned streams, and cancel or close started operations according to the language-specific API. ## Mixing client approaches diff --git a/docs/src/content/next/how-to-guides/effect/golem-agent-reflection-effect.mdx b/docs/src/content/next/how-to-guides/effect/golem-agent-reflection-effect.mdx index 1f7b4af634..75d140918e 100644 --- a/docs/src/content/next/how-to-guides/effect/golem-agent-reflection-effect.mdx +++ b/docs/src/content/next/how-to-guides/effect/golem-agent-reflection-effect.mdx @@ -81,7 +81,7 @@ Effect errors preserve the boundary that failed: - The host remains authoritative for visibility, authorization, environment-scoped identity resolution, effective configuration, and the deployed input schema. - Awaited calls verify unit/non-unit cardinality and declared output shape. `RemoteCallError`, `ToolRuntimeError`, custom agent errors, and custom tool payloads remain tagged values in the Effect error channel. -In Normal RPC and caller-defined static inputs, use `Schema.optional(...)` in a struct and omit the property. Canonical reflected JSON records contain every field, so represent an absent `option` with `null`: +In Normal RPC and caller-defined static inputs, use `Schema.optional(...)` in a struct and omit the property. Reflected JSON accepts either an omitted `option` record field or an explicit `null` as absent; re-encoding may include the field with `null`. Reflection JSON Schema does not list that field in `required`: ```ts import { Effect } from "effect" @@ -92,14 +92,16 @@ const optionalCall = Effect.scoped(Effect.gen(function* () { if (!type || type.mode !== "durable") return yield* Effect.fail("SearchAgent unavailable") const client = yield* type.client.get({ tenant: "docs" }) const search = yield* client.method("search") - const checked = search.definition.input.validateJson({ query: "golem", cursor: null }) + const checked = search.definition.input.validateJson({ query: "golem" }) if (!checked.success) return yield* Effect.fail(checked.issues) - return yield* search.invoke({ query: "golem", cursor: null }) + return yield* search.invoke({ query: "golem" }) })) ``` Canonical JSON represents `s64` and `u64` as base-10 strings. Duration is `{ nanoseconds: "..." }`; quantity uses a decimal-string `mantissa`. Smaller integers remain JSON numbers. JSON Schema projections expose the same canonical patterns and exact range metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected JSON. Their reflection JSON Schema projection is unsatisfiable; use schema-native value APIs for those leaves. + ## Cancellation, streams, and cleanup Agent calls are Effects: fiber interruption cancels result observation, while remote side effects may already have happened. A scheduled call returns a scoped cancel Effect. Keep the scope open until the cancellation token is no longer needed: diff --git a/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx b/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx index 2e5a037261..2b26b049df 100644 --- a/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx +++ b/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx @@ -31,7 +31,13 @@ Reflection clients do not pin that component ID. `SchemaRef::pack_json` converts canonical JSON into a schema-native value; `unpack_json` performs the awaited conversion back. `to_json_schema()` projects the canonical JSON carrier for introspection, including named definitions and representable restrictions; -capabilities and streams have no JSON value representation. Discovery returns +capabilities, futures, and streams have no JSON value representation and project +to an unsatisfiable reflection JSON Schema. An omitted option record field and +an explicit `null` both decode as absent; re-encoding may emit `null`, and the +field is omitted from JSON Schema `required`. Canonical JSON encodes `s64` and +`u64` as decimal strings, duration as `{ "nanoseconds": "..." }`, and quantity +mantissas as decimal strings. These strings reject `+`, leading zeroes, `-0`, +and overflow. Discovery returns an immutable snapshot; explicitly discover again when a newer deployment must be observed. diff --git a/docs/src/content/next/how-to-guides/rust/golem-agent-reflection-rust.mdx b/docs/src/content/next/how-to-guides/rust/golem-agent-reflection-rust.mdx index eee256b98a..91353dfaa5 100644 --- a/docs/src/content/next/how-to-guides/rust/golem-agent-reflection-rust.mdx +++ b/docs/src/content/next/how-to-guides/rust/golem-agent-reflection-rust.mdx @@ -28,14 +28,15 @@ Validation happens at three different boundaries: | Completion | Result cardinality, resolved graph equivalence, declared custom-error payloads, and value decoding | Execution failures and undeclared remote errors | Normal RPC and caller-defined Rust values use `Option`. Reflected canonical JSON -omits an optional record field or supplies its ordinary JSON value; when using +treats an omitted optional record field or an explicit `null` as absent; re-encoding +may include that field with `null`, and reflection JSON Schema omits it from +`required`. When using `SchemaValue` directly, use `SchemaValue::Option { inner: None }` or `SchemaValue::Option { inner: Some(Box::new(value)) }`. Optional tool scalar positionals and options use that option carrier. Repeatable/tail inputs use an empty list for absence, flags use their effective boolean/count value, and an explicit default is represented by the default value. Do not replace an absent -optional carrier with a zero, empty string, or `null` unless its declared schema -actually permits that value. +optional carrier with a zero or empty string. Canonical JSON represents `s64`, `u64`, duration nanoseconds, and quantity mantissas as canonical base-10 strings. Smaller integers remain JSON numbers. @@ -43,6 +44,10 @@ Packing rejects leading `+`, non-canonical leading zeroes, and out-of-range values before transport; projected JSON Schema uses the same string patterns and exposes the exact range as metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected +JSON. Their reflection JSON Schema projection is unsatisfiable; use +schema-native value APIs for those leaves. + ## Discover Agent Types Discover the agent types registered in the current environment: diff --git a/docs/src/content/next/how-to-guides/scala/golem-agent-reflection-scala.mdx b/docs/src/content/next/how-to-guides/scala/golem-agent-reflection-scala.mdx index d9d459e2a8..20be1788d7 100644 --- a/docs/src/content/next/how-to-guides/scala/golem-agent-reflection-scala.mdx +++ b/docs/src/content/next/how-to-guides/scala/golem-agent-reflection-scala.mdx @@ -29,6 +29,13 @@ Agent type names are unique in an environment. An `AgentType` exposes its current component ID, lifecycle mode, constructor `SchemaRef`, and method `SchemaRef`s. `SchemaRef` validates `SchemaValue`, packs and unpacks canonical `zio.blocks.schema.json.Json`, and renders JSON Schema. +An omitted option record field and an explicit `null` both decode as `None`; +re-encoding may emit `null`, and the field is omitted from JSON Schema +`required`. Canonical JSON encodes `s64` and `u64` as decimal strings, duration +as `{ "nanoseconds": "..." }`, and quantity mantissas as decimal strings. These +strings reject `+`, leading zeroes, `-0`, and overflow. Capabilities, futures, +and streams cannot be packed or unpacked as reflected JSON and project to an +unsatisfiable reflection JSON Schema. `getAgentType` returns `Right(None)` for a missing type and reserves `Left` for discovery or decoding failures. The returned schemas are an immutable snapshot; call discovery again when a newer deployment must be observed. diff --git a/docs/src/content/next/how-to-guides/ts/golem-agent-reflection-ts.mdx b/docs/src/content/next/how-to-guides/ts/golem-agent-reflection-ts.mdx index 2e5a193119..4d83ab2155 100644 --- a/docs/src/content/next/how-to-guides/ts/golem-agent-reflection-ts.mdx +++ b/docs/src/content/next/how-to-guides/ts/golem-agent-reflection-ts.mdx @@ -230,7 +230,7 @@ Validation happens at several boundaries: - The host authorizes the caller, resolves the environment-scoped identity, validates effective configuration, and checks the deployed input schema. - Awaited typed and reflected calls verify unit/non-unit cardinality and decode the declared result. Catch `RemoteCallError` with `isRemoteCallError(error)` and handle `error.cause` as a tagged value; do not parse messages. -In Normal RPC and caller-defined static inputs, declare optional fields with the schema library, for example `z.string().optional()`, and omit them normally. Canonical reflected JSON records contain every field, so pass `null` for an absent option: +In Normal RPC and caller-defined static inputs, declare optional fields with the schema library, for example `z.string().optional()`, and omit them normally. Reflected JSON accepts either an omitted option record field or an explicit `null` as absent; re-encoding may include the field with `null`. Reflection JSON Schema omits that field from `required`: ```typescript import { getReflectedAgentType } from '@golemcloud/golem-ts-sdk'; @@ -239,17 +239,18 @@ const type = getReflectedAgentType('SearchAgent'); const search = type?.method('search'); if (!type || !search || type.mode !== 'durable') throw new Error('SearchAgent.search unavailable'); -const checked = search.input.validateJson({ query: 'golem', cursor: null }); +const checked = search.input.validateJson({ query: 'golem' }); if (!checked.success) throw new Error(JSON.stringify(checked.issues)); const result = await type.client.get({ tenant: 'docs' }).method('search').invoke({ query: 'golem', - cursor: null, }); console.log(result.value); ``` Canonical JSON represents `s64` and `u64` as decimal strings. Duration is `{ nanoseconds: "..." }`, and quantity uses a decimal-string `mantissa`. Smaller integers remain numbers. The generated JSON Schema uses matching patterns and exact range metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected JSON. Their reflection JSON Schema projection is unsatisfiable; use schema-native value APIs for those leaves. + ## Cancellation, Streams, and Cleanup Cancel an awaited agent call with `AbortSignal`; cancel a scheduled durable call with its token. A triggered call has no result observer: diff --git a/golem-skills/skills/common/golem-agent-reflection/SKILL.md b/golem-skills/skills/common/golem-agent-reflection/SKILL.md index 4175e9fbb4..14f755d1c0 100644 --- a/golem-skills/skills/common/golem-agent-reflection/SKILL.md +++ b/golem-skills/skills/common/golem-agent-reflection/SKILL.md @@ -38,11 +38,11 @@ Creation-time configuration overrides are optional because component defaults ma ## Optional values and canonical JSON -Normal RPC and caller-defined static clients use the language's normal optional-field syntax. Reflected JSON is schema-shaped instead: records contain every declared field and an absent `option` is `null`. Tool defaults do not turn `Present` into a key-existence check; constraints evaluate the effective optional/default carrier, and nested `ValueIs` comparisons use the declared nested schema. +Normal RPC and caller-defined static clients use the language's normal optional-field syntax. In reflected JSON, an omitted `option` record field and an explicit `null` both decode as absent; re-encoding may include the field with `null`. Reflection JSON Schema therefore omits optional fields from `required`. Tool defaults do not turn `Present` into a key-existence check; constraints evaluate the effective optional/default carrier, and nested `ValueIs` comparisons use the declared nested schema. Canonical invocation JSON uses JSON numbers for integers through 32 bits. Signed and unsigned 64-bit integers are canonical base-10 strings, as are duration nanoseconds and quantity mantissas. A duration is `{ "nanoseconds": "..." }`; a quantity is `{ "mantissa": "...", "scale": number, "unit": string }`. Leading zeroes, `+`, and `-0` are invalid. JSON Schema projections use the same string patterns and carry the exact range as metadata. -Streams and opaque capabilities have no canonical JSON representation. Use the schema-native `*Value` APIs, transfer each owned handle once, consume returned streams, and cancel or close started operations according to the language-specific API. +Streams, futures, and opaque capabilities have no canonical JSON representation. Their reflection JSON Schema projection is unsatisfiable. Use the schema-native `*Value` APIs, transfer each owned handle once, consume returned streams, and cancel or close started operations according to the language-specific API. ## Mixing client approaches diff --git a/golem-skills/skills/effect/golem-agent-reflection-effect/SKILL.md b/golem-skills/skills/effect/golem-agent-reflection-effect/SKILL.md index c4f8da48a6..e3b745f094 100644 --- a/golem-skills/skills/effect/golem-agent-reflection-effect/SKILL.md +++ b/golem-skills/skills/effect/golem-agent-reflection-effect/SKILL.md @@ -86,7 +86,7 @@ Effect errors preserve the boundary that failed: - The host remains authoritative for visibility, authorization, environment-scoped identity resolution, effective configuration, and the deployed input schema. - Awaited calls verify unit/non-unit cardinality and declared output shape. `RemoteCallError`, `ToolRuntimeError`, custom agent errors, and custom tool payloads remain tagged values in the Effect error channel. -In Normal RPC and caller-defined static inputs, use `Schema.optional(...)` in a struct and omit the property. Canonical reflected JSON records contain every field, so represent an absent `option` with `null`: +In Normal RPC and caller-defined static inputs, use `Schema.optional(...)` in a struct and omit the property. Reflected JSON accepts either an omitted `option` record field or an explicit `null` as absent; re-encoding may include the field with `null`. Reflection JSON Schema does not list that field in `required`: ```ts import { Effect } from "effect" @@ -97,14 +97,16 @@ const optionalCall = Effect.scoped(Effect.gen(function* () { if (!type || type.mode !== "durable") return yield* Effect.fail("SearchAgent unavailable") const client = yield* type.client.get({ tenant: "docs" }) const search = yield* client.method("search") - const checked = search.definition.input.validateJson({ query: "golem", cursor: null }) + const checked = search.definition.input.validateJson({ query: "golem" }) if (!checked.success) return yield* Effect.fail(checked.issues) - return yield* search.invoke({ query: "golem", cursor: null }) + return yield* search.invoke({ query: "golem" }) })) ``` Canonical JSON represents `s64` and `u64` as base-10 strings. Duration is `{ nanoseconds: "..." }`; quantity uses a decimal-string `mantissa`. Smaller integers remain JSON numbers. JSON Schema projections expose the same canonical patterns and exact range metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected JSON. Their reflection JSON Schema projection is unsatisfiable; use schema-native value APIs for those leaves. + ## Cancellation, streams, and cleanup Agent calls are Effects: fiber interruption cancels result observation, while remote side effects may already have happened. A scheduled call returns a scoped cancel Effect. Keep the scope open until the cancellation token is no longer needed: diff --git a/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md b/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md index 7aad9fc89a..2a864a2c3a 100644 --- a/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md +++ b/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md @@ -36,7 +36,13 @@ Reflection clients do not pin that component ID. `SchemaRef::pack_json` converts canonical JSON into a schema-native value; `unpack_json` performs the awaited conversion back. `to_json_schema()` projects the canonical JSON carrier for introspection, including named definitions and representable restrictions; -capabilities and streams have no JSON value representation. Discovery returns +capabilities, futures, and streams have no JSON value representation and project +to an unsatisfiable reflection JSON Schema. An omitted option record field and +an explicit `null` both decode as absent; re-encoding may emit `null`, and the +field is omitted from JSON Schema `required`. Canonical JSON encodes `s64` and +`u64` as decimal strings, duration as `{ "nanoseconds": "..." }`, and quantity +mantissas as decimal strings. These strings reject `+`, leading zeroes, `-0`, +and overflow. Discovery returns an immutable snapshot; explicitly discover again when a newer deployment must be observed. diff --git a/golem-skills/skills/rust/golem-agent-reflection-rust/SKILL.md b/golem-skills/skills/rust/golem-agent-reflection-rust/SKILL.md index 4fa981986c..a7ed4e19ec 100644 --- a/golem-skills/skills/rust/golem-agent-reflection-rust/SKILL.md +++ b/golem-skills/skills/rust/golem-agent-reflection-rust/SKILL.md @@ -33,14 +33,15 @@ Validation happens at three different boundaries: | Completion | Result cardinality, resolved graph equivalence, declared custom-error payloads, and value decoding | Execution failures and undeclared remote errors | Normal RPC and caller-defined Rust values use `Option`. Reflected canonical JSON -omits an optional record field or supplies its ordinary JSON value; when using +treats an omitted optional record field or an explicit `null` as absent; re-encoding +may include that field with `null`, and reflection JSON Schema omits it from +`required`. When using `SchemaValue` directly, use `SchemaValue::Option { inner: None }` or `SchemaValue::Option { inner: Some(Box::new(value)) }`. Optional tool scalar positionals and options use that option carrier. Repeatable/tail inputs use an empty list for absence, flags use their effective boolean/count value, and an explicit default is represented by the default value. Do not replace an absent -optional carrier with a zero, empty string, or `null` unless its declared schema -actually permits that value. +optional carrier with a zero or empty string. Canonical JSON represents `s64`, `u64`, duration nanoseconds, and quantity mantissas as canonical base-10 strings. Smaller integers remain JSON numbers. @@ -48,6 +49,10 @@ Packing rejects leading `+`, non-canonical leading zeroes, and out-of-range values before transport; projected JSON Schema uses the same string patterns and exposes the exact range as metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected +JSON. Their reflection JSON Schema projection is unsatisfiable; use +schema-native value APIs for those leaves. + ## Discover Agent Types Discover the agent types registered in the current environment: diff --git a/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md b/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md index c20bd4656a..1892501d7c 100644 --- a/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md +++ b/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md @@ -34,6 +34,13 @@ Agent type names are unique in an environment. An `AgentType` exposes its current component ID, lifecycle mode, constructor `SchemaRef`, and method `SchemaRef`s. `SchemaRef` validates `SchemaValue`, packs and unpacks canonical `zio.blocks.schema.json.Json`, and renders JSON Schema. +An omitted option record field and an explicit `null` both decode as `None`; +re-encoding may emit `null`, and the field is omitted from JSON Schema +`required`. Canonical JSON encodes `s64` and `u64` as decimal strings, duration +as `{ "nanoseconds": "..." }`, and quantity mantissas as decimal strings. These +strings reject `+`, leading zeroes, `-0`, and overflow. Capabilities, futures, +and streams cannot be packed or unpacked as reflected JSON and project to an +unsatisfiable reflection JSON Schema. `getAgentType` returns `Right(None)` for a missing type and reserves `Left` for discovery or decoding failures. The returned schemas are an immutable snapshot; call discovery again when a newer deployment must be observed. diff --git a/golem-skills/skills/ts/golem-agent-reflection-ts/SKILL.md b/golem-skills/skills/ts/golem-agent-reflection-ts/SKILL.md index 52e0c84375..9fab6949b6 100644 --- a/golem-skills/skills/ts/golem-agent-reflection-ts/SKILL.md +++ b/golem-skills/skills/ts/golem-agent-reflection-ts/SKILL.md @@ -235,7 +235,7 @@ Validation happens at several boundaries: - The host authorizes the caller, resolves the environment-scoped identity, validates effective configuration, and checks the deployed input schema. - Awaited typed and reflected calls verify unit/non-unit cardinality and decode the declared result. Catch `RemoteCallError` with `isRemoteCallError(error)` and handle `error.cause` as a tagged value; do not parse messages. -In Normal RPC and caller-defined static inputs, declare optional fields with the schema library, for example `z.string().optional()`, and omit them normally. Canonical reflected JSON records contain every field, so pass `null` for an absent option: +In Normal RPC and caller-defined static inputs, declare optional fields with the schema library, for example `z.string().optional()`, and omit them normally. Reflected JSON accepts either an omitted option record field or an explicit `null` as absent; re-encoding may include the field with `null`. Reflection JSON Schema omits that field from `required`: ```typescript import { getReflectedAgentType } from '@golemcloud/golem-ts-sdk'; @@ -244,17 +244,18 @@ const type = getReflectedAgentType('SearchAgent'); const search = type?.method('search'); if (!type || !search || type.mode !== 'durable') throw new Error('SearchAgent.search unavailable'); -const checked = search.input.validateJson({ query: 'golem', cursor: null }); +const checked = search.input.validateJson({ query: 'golem' }); if (!checked.success) throw new Error(JSON.stringify(checked.issues)); const result = await type.client.get({ tenant: 'docs' }).method('search').invoke({ query: 'golem', - cursor: null, }); console.log(result.value); ``` Canonical JSON represents `s64` and `u64` as decimal strings. Duration is `{ nanoseconds: "..." }`, and quantity uses a decimal-string `mantissa`. Smaller integers remain numbers. The generated JSON Schema uses matching patterns and exact range metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected JSON. Their reflection JSON Schema projection is unsatisfiable; use schema-native value APIs for those leaves. + ## Cancellation, Streams, and Cleanup Cancel an awaited agent call with `AbortSignal`; cancel a scheduled durable call with its token. A triggered call has no result observer: From 44ac30d9094c16295c6ce3da35f6a5c677f2824b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 14:48:18 +0200 Subject: [PATCH 14/37] test: format reflection corpus runners --- .../test/reflection-conformance.test.ts | 73 ++++++++++++++++--- .../tests/reflection-conformance.test.ts | 73 ++++++++++++++++--- 2 files changed, 122 insertions(+), 24 deletions(-) diff --git a/sdks/effect/test/reflection-conformance.test.ts b/sdks/effect/test/reflection-conformance.test.ts index 0636d748ed..15bb3ada23 100644 --- a/sdks/effect/test/reflection-conformance.test.ts +++ b/sdks/effect/test/reflection-conformance.test.ts @@ -29,21 +29,70 @@ const corpus = JSON.parse( ) as ConformanceCorpus const supportedSchemaKinds = [ - "ref", "bool", "s8", "s16", "s32", "s64", "u8", "u16", "u32", "u64", - "f32", "f64", "char", "string", "record", "variant", "enum", "flags", "tuple", - "list", "fixed-list", "map", "option", "result", "text", "binary", "path", "url", - "datetime", "duration", "quantity", "union", "secret", "quota-token", - "permission-card", "future", "stream", + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream", ] as const const supportedRestrictionKinds = [ - "numeric-minimum", "numeric-maximum", "numeric-unit", - "text-languages", "text-min-length", "text-max-length", "text-regex", - "binary-mime-types", "binary-min-bytes", "binary-max-bytes", - "path-direction", "path-kind", "path-mime-types", "path-extensions", - "url-schemes", "url-hosts", "quantity-base-unit", "quantity-suffixes", - "quantity-minimum", "quantity-maximum", - "union-prefix", "union-suffix", "union-regex", "union-field", + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field", ] as const const ref = (root: SchemaType) => new SchemaRef(schemaGraphToWit({ defs: new Map(), root })) diff --git a/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts index f25c2f7c58..6280225338 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts @@ -42,21 +42,70 @@ const corpus = JSON.parse( ) as ConformanceCorpus; const supportedSchemaKinds = [ - 'ref', 'bool', 's8', 's16', 's32', 's64', 'u8', 'u16', 'u32', 'u64', - 'f32', 'f64', 'char', 'string', 'record', 'variant', 'enum', 'flags', 'tuple', - 'list', 'fixed-list', 'map', 'option', 'result', 'text', 'binary', 'path', 'url', - 'datetime', 'duration', 'quantity', 'union', 'secret', 'quota-token', - 'permission-card', 'future', 'stream', + 'ref', + 'bool', + 's8', + 's16', + 's32', + 's64', + 'u8', + 'u16', + 'u32', + 'u64', + 'f32', + 'f64', + 'char', + 'string', + 'record', + 'variant', + 'enum', + 'flags', + 'tuple', + 'list', + 'fixed-list', + 'map', + 'option', + 'result', + 'text', + 'binary', + 'path', + 'url', + 'datetime', + 'duration', + 'quantity', + 'union', + 'secret', + 'quota-token', + 'permission-card', + 'future', + 'stream', ] as const; const supportedRestrictionKinds = [ - 'numeric-minimum', 'numeric-maximum', 'numeric-unit', - 'text-languages', 'text-min-length', 'text-max-length', 'text-regex', - 'binary-mime-types', 'binary-min-bytes', 'binary-max-bytes', - 'path-direction', 'path-kind', 'path-mime-types', 'path-extensions', - 'url-schemes', 'url-hosts', 'quantity-base-unit', 'quantity-suffixes', - 'quantity-minimum', 'quantity-maximum', - 'union-prefix', 'union-suffix', 'union-regex', 'union-field', + 'numeric-minimum', + 'numeric-maximum', + 'numeric-unit', + 'text-languages', + 'text-min-length', + 'text-max-length', + 'text-regex', + 'binary-mime-types', + 'binary-min-bytes', + 'binary-max-bytes', + 'path-direction', + 'path-kind', + 'path-mime-types', + 'path-extensions', + 'url-schemes', + 'url-hosts', + 'quantity-base-unit', + 'quantity-suffixes', + 'quantity-minimum', + 'quantity-maximum', + 'union-prefix', + 'union-suffix', + 'union-regex', + 'union-field', ] as const; function schema(root: SchemaType): SchemaRef { From 0aa8402f49a8ad35b92b0862843b20217069cb69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 14:51:22 +0200 Subject: [PATCH 15/37] effect: accept omitted recursive option fields --- sdks/effect/test/reflection.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdks/effect/test/reflection.test.ts b/sdks/effect/test/reflection.test.ts index e5e4774bca..5c8a1de027 100644 --- a/sdks/effect/test/reflection.test.ts +++ b/sdks/effect/test/reflection.test.ts @@ -87,7 +87,11 @@ describe("reflection", () => { const ref = new SchemaRef(schemaGraphToWit(recursive)) const json = { label: "first", next: { label: "last", next: null } } expect(ref.unpackJson(ref.packJson(json))).toEqual(json) - expect(ref.validateJson({ label: "missing-next" })).toMatchObject({ success: false }) + expect(ref.validateJson({ label: "missing-next" })).toMatchObject({ success: true }) + expect(ref.unpackJson(ref.packJson({ label: "missing-next" }))).toEqual({ + label: "missing-next", + next: null, + }) expect(ref.validateValue(ref.packJson(json))).toMatchObject({ success: true }) expect(ref.toJsonSchema()).toMatchObject({ $schema: expect.any(String) }) expect(Object.isFrozen(ref.graph)).toBe(true) From b0727d50151c2fa99b6872c86a2f099e431d6e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 15:00:44 +0200 Subject: [PATCH 16/37] typescript: accept omitted reflected options --- .../golem-ts-sdk/tests/tool-reflection.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/sdks/ts/packages/golem-ts-sdk/tests/tool-reflection.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/tool-reflection.test.ts index 19ad3f8098..e99988558f 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/tool-reflection.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/tool-reflection.test.ts @@ -217,20 +217,24 @@ describe('native tool reflection', () => { }); }); - it('builds a canonical input schema and rejects invalid JSON before dispatch', async () => { + it('builds a canonical input schema and accepts omitted options before dispatch', async () => { const { tool, start } = fixture(); const command = tool.client.command([]); expect(command.inputSchema?.toJsonSchema()).toBeDefined(); expect(command.validateJson({ value: 'hello', maybe: null }).success).toBe(true); - expect(command.validateJson({ value: 'hello' }).success).toBe(false); + expect(command.validateJson({ value: 'hello' }).success).toBe(true); expect(start).not.toHaveBeenCalled(); - for (const maybe of [null, 'supplied']) { - await expect(command.invokeJson({ value: 'hello', maybe })).resolves.toBe('ok'); + for (const input of [ + { value: 'hello' }, + { value: 'hello', maybe: null }, + { value: 'hello', maybe: 'supplied' }, + ]) { + await expect(command.invokeJson(input)).resolves.toBe('ok'); const sent = start.mock.calls.at(-1)![1]; expect(schemaShapesMatch(command.inputSchema!.graph, sent.graph)).toBe(true); expect(command.inputSchema!.validateValue(sent.value).success).toBe(true); } - expect(start).toHaveBeenCalledTimes(2); + expect(start).toHaveBeenCalledTimes(3); }); it('rejects a missing declared remote result', async () => { From 114b57b76fa12cbc9fecca64c078e0d60c093389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 18:28:51 +0200 Subject: [PATCH 17/37] scala: align bridge JSON payloads --- cli/golem-cli/src/bridge_gen/scala/mod.rs | 28 ++------- .../runtime/golem/bridge/runtime/Bridge.scala | 5 +- .../golem/bridge/runtime/BridgeProtocol.scala | 4 +- .../bridge/runtime/PublicValueCodec.scala | 10 +-- .../bridge/runtime/SchemaValueCodec.scala | 4 +- .../golem/bridge/runtime/StreamSession.scala | 7 +-- cli/golem-cli/tests/bridge_gen/scala.rs | 63 +++++++++++++++++-- 7 files changed, 76 insertions(+), 45 deletions(-) diff --git a/cli/golem-cli/src/bridge_gen/scala/mod.rs b/cli/golem-cli/src/bridge_gen/scala/mod.rs index cbc1ac1fb1..897d9853e8 100644 --- a/cli/golem-cli/src/bridge_gen/scala/mod.rs +++ b/cli/golem-cli/src/bridge_gen/scala/mod.rs @@ -1558,9 +1558,8 @@ impl ScalaBridgeGenerator { || "_root_.scala.None".to_string(), |codec| format!("_root_.scala.Some({codec})"), ); - let config_codecs = self.public_config_codecs()?; writer.line(format!( - "{BRIDGE}.invokeStreamingAgent(resolved, {method_name_lit}, () => methodParameters({invoke_args}), {constructor_codec}, {input_codec}, {output_codec}, {config_codecs}).map {{ __result =>" + "{BRIDGE}.invokeStreamingAgent(resolved, {method_name_lit}, () => methodParameters({invoke_args}), {constructor_codec}, {input_codec}, {output_codec}).map {{ __result =>" )); } else { writer.line(format!( @@ -1919,7 +1918,7 @@ impl ScalaBridgeGenerator { /// Emits `val agentConfig = List(, …).flatten`, where each `` /// is the matching config parameter mapped (when present) to an - /// `AgentConfigEntry(path, encodedValue)`. + /// `AgentConfigEntry(path, encodedValue, publicCodec)`. fn write_config_list( &self, writer: &mut ScalaWriter, @@ -1937,6 +1936,7 @@ impl ScalaBridgeGenerator { .collect::>() .join(", "); let enc = self.encode_expr("value", &config.value_type, 0)?; + let codec = self.public_codec(&config.value_type)?; let comma = if idx + 1 < local_configs.len() { "," } else { @@ -1946,7 +1946,7 @@ impl ScalaBridgeGenerator { writer.indent(); writer.line(format!("val configValue = {enc}")); writer.line(format!( - "{AGENT_CONFIG_ENTRY}({LIST}({path_lit}), configValue)" + "{AGENT_CONFIG_ENTRY}({LIST}({path_lit}), configValue, {codec})" )); writer.dedent(); writer.line(format!("}}{comma}")); @@ -2036,26 +2036,6 @@ impl ScalaBridgeGenerator { )) } - fn public_config_codecs(&self) -> anyhow::Result { - let entries = self - .local_configs() - .into_iter() - .map(|config| { - let path = config - .path - .iter() - .map(|part| scala_string_literal(part)) - .collect::>() - .join(", "); - Ok(format!( - "{LIST}({path}) -> {}", - self.public_codec(&config.value_type)? - )) - }) - .collect::>>()?; - Ok(format!("{LIST}({})", entries.join(", "))) - } - /// The `(name, type)` parameter declarations for a constructor or method's /// user-supplied input fields, in declaration order. A multimodal input is /// surfaced as a single `List[Multimodal]` parameter named after the diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala index f28dd13eb4..6ed0ad180c 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala @@ -144,8 +144,7 @@ object Bridge { methodParameters: () => SchemaValue, constructorCodec: PublicValueCodec.Codec, inputCodec: PublicValueCodec.Codec, - outputCodec: Option[PublicValueCodec.Codec], - configCodecs: List[(List[String], PublicValueCodec.Codec)] + outputCodec: Option[PublicValueCodec.Codec] ): Future[AgentInvocationResult] = - StreamSession.invoke(resolved, methodName, methodParameters, constructorCodec, inputCodec, outputCodec, configCodecs) + StreamSession.invoke(resolved, methodName, methodParameters, constructorCodec, inputCodec, outputCodec) } diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala index 081a24ca55..629ab9c5c0 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala @@ -19,7 +19,7 @@ package golem.bridge.runtime import golem.bridge.runtime.json.Json /** A single agent configuration override entry of a create-agent request. */ -final case class AgentConfigEntry(path: List[String], value: SchemaValue) +final case class AgentConfigEntry(path: List[String], value: SchemaValue, codec: PublicValueCodec.Codec) /** Body of a `POST /v1/agents/create-agent` request. */ final case class CreateAgentRequest( @@ -118,7 +118,7 @@ object BridgeProtocol { private def encodeConfigEntry(entry: AgentConfigEntry): Json = Json.obj( "path" -> Json.arr(entry.path.map(Json.string).toVector), - "value" -> SchemaValueCodec.toJson(entry.value) + "value" -> entry.codec.encode(entry.value) ) def decodeCreateAgentResponse(json: Json): Either[String, CreateAgentResponse] = diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala index c5201180aa..68bf661349 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala @@ -118,7 +118,7 @@ object PublicValueCodec { json.Json.obj(Vector("text" -> json.Json.string(text)) ++ language.map(v => "language" -> json.Json.string(v))) case ("binary", BinaryValue(bytes, mimeType)) => validateBinary(schema, bytes, mimeType); budget.add(bytes.length); mimeType.foreach(budget.string) - json.Json.obj(Vector("bytes" -> json.Json.string(Base64.getEncoder.encodeToString(bytes.toArray))) ++ mimeType.map(v => "mimeType" -> json.Json.string(v))) + json.Json.obj(Vector("bytes" -> json.Json.string(Base64.getUrlEncoder.withoutPadding().encodeToString(bytes.toArray))) ++ mimeType.map(v => "mimeType" -> json.Json.string(v))) case ("path", PathValue(v)) => validatePath(schema, v); budget.string(v); json.Json.string(v) case ("url", UrlValue(v)) => validateUrl(schema, v); budget.string(v); json.Json.string(v) case ("datetime", DatetimeValue(v)) => validateDatetime(v); budget.string(v); json.Json.string(v) @@ -470,9 +470,9 @@ object PublicValueCodec { } private def decodeBase64(value: String): Vector[Byte] = { - if (value.length % 4 != 0 || !Base64Syntax.matcher(value).matches()) fail("binary bytes are not canonical padded base64") - val bytes = try Base64.getDecoder.decode(value) catch { case _: IllegalArgumentException => fail("invalid base64") } - if (Base64.getEncoder.encodeToString(bytes) != value) fail("binary bytes are not canonical padded base64") + if (value.length % 4 == 1 || !Base64UrlSyntax.matcher(value).matches()) fail("binary bytes are not canonical unpadded base64url") + val bytes = try Base64.getUrlDecoder.decode(value) catch { case _: IllegalArgumentException => fail("invalid base64url") } + if (Base64.getUrlEncoder.withoutPadding().encodeToString(bytes) != value) fail("binary bytes are not canonical unpadded base64url") bytes.toVector } @@ -605,7 +605,7 @@ object PublicValueCodec { private val UnsignedDecimal = Pattern.compile("0|[1-9][0-9]*") private val Mime = Pattern.compile("[A-Za-z0-9!#$&^_.+\\-]+/[A-Za-z0-9!#$&^_.+\\-]+") private val Language = Pattern.compile("[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*") - private val Base64Syntax = Pattern.compile("(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?") + private val Base64UrlSyntax = Pattern.compile("[A-Za-z0-9_-]*") private val UuidV4 = Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") private val Datetime = Pattern.compile("[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]{1,9})?Z") } diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/SchemaValueCodec.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/SchemaValueCodec.scala index 9d941bf04d..fab5934be9 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/SchemaValueCodec.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/SchemaValueCodec.scala @@ -108,7 +108,7 @@ object SchemaValueCodec { "bytes" -> Json.arr(bytes.map(b => Json.fromInt(b & 0xff))) ) val withMime = mimeType match { - case Some(m) => base :+ ("mime_type" -> Json.string(m)) + case Some(m) => base :+ ("mimeType" -> Json.string(m)) case None => base } node("binary", Json.obj(withMime)) @@ -451,7 +451,7 @@ object SchemaValueCodec { case "binary" => for { bytes <- field(value, "bytes").flatMap(Json.asArray).flatMap(decodeBytes) - mimeType <- optionalStringField(value, "mime_type") + mimeType <- optionalStringField(value, "mimeType") } yield BinaryValue(bytes, mimeType) case "path" => field(value, "path").flatMap(Json.asString).map(PathValue(_)) case "url" => field(value, "url").flatMap(Json.asString).map(UrlValue(_)) diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/StreamSession.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/StreamSession.scala index 7de32e5268..117a5e56b3 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/StreamSession.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/StreamSession.scala @@ -204,8 +204,7 @@ object StreamSession { parameters: () => SchemaValue, constructorCodec: PublicValueCodec.Codec, inputCodec: PublicValueCodec.Codec, - outputCodec: Option[PublicValueCodec.Codec], - configCodecs: List[(List[String], PublicValueCodec.Codec)] + outputCodec: Option[PublicValueCodec.Codec] ): Future[AgentInvocationResult] = { implicit val ec: ExecutionContext = resolved.configuration.executionContext val session = new Session(resolved) @@ -730,10 +729,8 @@ object StreamSession { } val base = resolved.configuration.server.url.stripSuffix("/").replaceFirst("^http", "ws") val selector = Json.obj("agentType" -> Json.string(resolved.agentTypeName), "application" -> Json.string(resolved.configuration.appName), "constructorParameters" -> constructorCodec.encode(resolved.parameters), "environment" -> Json.string(resolved.configuration.envName), "method" -> Json.string(method)) - val codecsByPath = configCodecs.toMap val config = Json.arr(resolved.config.map { e => - val codec = codecsByPath.getOrElse(e.path, throw BridgeException(s"missing public config codec for ${e.path.mkString(".")}")) - Json.obj("path" -> Json.arr(e.path.map(Json.string).toVector), "value" -> codec.encode(e.value)) + Json.obj("path" -> Json.arr(e.path.map(Json.string).toVector), "value" -> e.codec.encode(e.value)) }.toVector) pendingAttempt = UUID.randomUUID.toString pendingDescriptor = StreamSessionProtocol.message("invocationStart", Vector("attemptId" -> Json.string(pendingAttempt), "config" -> config, "idempotencyKey" -> Json.string(idempotencyKey), "methodParameters" -> inputCodec.encode(encodedParameters), "selector" -> selector)) diff --git a/cli/golem-cli/tests/bridge_gen/scala.rs b/cli/golem-cli/tests/bridge_gen/scala.rs index 3f9239c965..2c227c7599 100644 --- a/cli/golem-cli/tests/bridge_gen/scala.rs +++ b/cli/golem-cli/tests/bridge_gen/scala.rs @@ -419,6 +419,13 @@ fn stream_runtime_state_semantics_execute() { ) .unwrap(); } + std::fs::copy( + workspace_root() + .unwrap() + .join("test-data/reflection-conformance/v1.json"), + pkg.package_dir().join("reflection-conformance.json"), + ) + .unwrap(); run_sbt_test( pkg.package_dir().as_path(), r#"package golem.bridge.runtime @@ -431,6 +438,15 @@ import golem.bridge.runtime.StreamSessionState.* import golem.bridge.runtime.json.Json class StreamRuntimeTest extends munit.FunSuite { + private val reflectionCorpus = Json.parse( + java.nio.file.Files.readString(java.nio.file.Path.of("reflection-conformance.json")) + ).toOption.get + + private def corpusCase(id: String): Json = + Json.asArray(Json.requireField(reflectionCorpus, "cases").toOption.get).toOption.get + .find(value => Json.field(value, "id").flatMap(Json.asString(_).toOption).contains(id)) + .getOrElse(fail(s"missing reflection conformance case $id")) + test("text codec directly consumes frozen canonical and malformed fixtures") { val messages = Json.parse(java.nio.file.Files.readString(java.nio.file.Path.of("json-messages.json"))).toOption.get Json.asArray(Json.requireField(messages, "vectors").toOption.get).toOption.get.foreach { vector => @@ -505,11 +521,51 @@ class StreamRuntimeTest extends munit.FunSuite { SchemaValue.BinaryValue(Vector[Byte](0, -1), Some("application/octet-stream")) )) val encoded = codec.encode(value) - assertEquals(encoded.render, """{"count":"-9223372036854775808","data":{"bytes":"AP8=","mimeType":"application/octet-stream"}}""") + assertEquals(encoded.render, """{"count":"-9223372036854775808","data":{"bytes":"AP8","mimeType":"application/octet-stream"}}""") assertEquals(codec.decode(encoded), value) intercept[BridgeException](codec.decode(Json.parse("""{"count":"1","data":{"bytes":"AA==","mimeType":"application/octet-stream"}}""").toOption.get)) } + test("reflection corpus drives tagged binary public binary and config request wires") { + val binaryCase = corpusCase("canonical/binary-mime") + val binaryExpected = Json.requireField(binaryCase, "expected").toOption.get + val mimeType = Json.asString(Json.requireField(binaryExpected, "mimeType").toOption.get).toOption.get + val binaryValue = SchemaValue.BinaryValue(Vector[Byte](-5, -1), Some(mimeType)) + assertEquals( + SchemaValueCodec.toJson(binaryValue).render, + """{"kind":"binary","value":{"bytes":[251,255],"mimeType":"application/octet-stream"}}""" + ) + + val binaryCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"binary","value":{"restrictions":{}}}}""" + ) + assertEquals(binaryCodec.encode(binaryValue), binaryExpected) + val rejected = Json.asArray( + Json.requireField(corpusCase("errors/binary-noncanonical-base64"), "inputs").toOption.get + ).toOption.get + rejected.foreach(input => intercept[BridgeException](binaryCodec.decode(input))) + + val configCase = corpusCase("config/canonical-entry") + val configExpected = Json.requireField(configCase, "expected").toOption.get + val path = Json.asArray(Json.requireField(configExpected, "path").toOption.get).toOption.get + .map(value => Json.asString(value).toOption.get).toList + val s64Codec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"s64","value":{}}}""" + ) + val request = CreateAgentRequest( + "app", + "env", + "ConfigAgent", + SchemaValue.TupleValue(List.empty), + None, + List(AgentConfigEntry(path, SchemaValue.S64Value(Long.MaxValue), s64Codec)) + ) + val envelope = BridgeProtocol.encodeCreateAgentRequest(request) + val config = Json.asArray(Json.requireField(envelope, "config").toOption.get).toOption.get + assertEquals(config, Vector(configExpected)) + assertEquals(Json.requireField(config.head, "value").toOption.get, Json.string(Long.MaxValue.toString)) + } + test("public codec enforces quantity bounds restrictions unions and integer boundaries") { val quantity = PublicValueCodec.fromSchemaGraphJson("""{"root":{"kind":"quantity","value":{"spec":{"baseUnit":"m","allowedSuffixes":["cm"],"min":{"mantissa":1,"scale":0,"unit":"m"},"max":{"mantissa":200,"scale":0,"unit":"m"}}}}}""") val q = SchemaValue.QuantityValue(150, 0, "cm") @@ -621,7 +677,6 @@ class StreamRuntimeTest extends munit.FunSuite { codec, codec, None, - List.empty, ) intercept[Throwable](Await.result(stream.consume(), 1.second)) @@ -1779,10 +1834,10 @@ fn local_config_overrides_compile() { // Supplied overrides build `AgentConfigEntry` values keyed by the path. assert!(client.contains( - "_root_.golem.bridge.runtime.AgentConfigEntry(_root_.scala.collection.immutable.List(\"db\", \"host\"), configValue)" + "_root_.golem.bridge.runtime.AgentConfigEntry(_root_.scala.collection.immutable.List(\"db\", \"host\"), configValue, _root_.golem.bridge.runtime.PublicValueCodec.fromSchemaGraphJson(" )); assert!(client.contains( - "_root_.golem.bridge.runtime.AgentConfigEntry(_root_.scala.collection.immutable.List(\"max-retries\"), configValue)" + "_root_.golem.bridge.runtime.AgentConfigEntry(_root_.scala.collection.immutable.List(\"max-retries\"), configValue, _root_.golem.bridge.runtime.PublicValueCodec.fromSchemaGraphJson(" )); // The plain constructors pass an empty config list. From c6ee205a49d5aa3892e7bfe9e0424118fc490fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 18:29:46 +0200 Subject: [PATCH 18/37] moonbit: align bridge JSON payloads --- .../bridge_gen/moonbit/runtime/json_codec.mbt | 4 +- .../bridge_gen/moonbit/runtime/protocol.mbt | 8 +- .../moonbit/runtime/public_value_codec.mbt | 60 ++++++------ .../moonbit/runtime/stream_wbtest.mbt | 2 +- cli/golem-cli/tests/bridge_gen/moonbit.rs | 97 +++++++++++++++++++ 5 files changed, 132 insertions(+), 39 deletions(-) diff --git a/cli/golem-cli/src/bridge_gen/moonbit/runtime/json_codec.mbt b/cli/golem-cli/src/bridge_gen/moonbit/runtime/json_codec.mbt index c1bd82cc45..7c0e78b3b3 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/runtime/json_codec.mbt +++ b/cli/golem-cli/src/bridge_gen/moonbit/runtime/json_codec.mbt @@ -184,7 +184,7 @@ pub fn schema_value_to_json(value : SchemaValue) -> Json { } let fields : Array[(String, Json)] = [("bytes", Json::array(arr))] match mime_type { - Some(m) => fields.push(("mime_type", Json::string(m))) + Some(m) => fields.push(("mimeType", Json::string(m))) None => () } node("binary", obj(fields)) @@ -417,7 +417,7 @@ pub fn schema_value_from_json(j : Json) -> SchemaValue raise { for b in arr { buf.write_byte(parse_int_range(b, 0, 255, "byte").to_byte()) } - let mime = match field_opt(m, "mime_type") { + let mime = match field_opt(m, "mimeType") { Some(x) => Some(expect_string(x)) None => None } diff --git a/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt b/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt index d9c24e72ac..6d47670595 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt +++ b/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt @@ -73,9 +73,9 @@ pub(all) struct ResolvedAgent { } ///| -fn encode_config_entry(entry : AgentConfigEntry) -> Json { +fn encode_config_entry(entry : AgentConfigEntry) -> Json raise { let path : Array[Json] = entry.path.map(Json::string) - obj([("path", Json::array(path)), ("value", schema_value_to_json(entry.value))]) + obj([("path", Json::array(path)), ("value", entry.codec.encode(entry.value))]) } ///| @@ -86,7 +86,7 @@ fn encode_create_agent_request( parameters : SchemaValue, phantom_id : String?, config : Array[AgentConfigEntry], -) -> Json { +) -> Json raise { let fields : Array[(String, Json)] = [ ("appName", Json::string(app_name)), ("envName", Json::string(env_name)), @@ -114,7 +114,7 @@ fn encode_agent_invocation_request( mode : String, schedule_at : String?, idempotency_key : String?, -) -> Json { +) -> Json raise { let fields : Array[(String, Json)] = [ ("appName", Json::string(app_name)), ("envName", Json::string(env_name)), diff --git a/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt b/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt index 531233ac45..b75a0c9843 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt +++ b/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt @@ -673,9 +673,9 @@ fn pvc_b64_value(c : Char) -> Int { c.to_int() - 'a'.to_int() + 26 } else if c >= '0' && c <= '9' { c.to_int() - '0'.to_int() + 52 - } else if c == '+' { + } else if c == '-' { 62 - } else if c == '/' { + } else if c == '_' { 63 } else { -1 @@ -685,25 +685,25 @@ fn pvc_b64_value(c : Char) -> Int { ///| fn pvc_base64_decode(value : String) -> Bytes raise { let chars = value.to_array() - if chars.length() % 4 != 0 { - pvc_fail("base64 padding is missing") + if chars.length() % 4 == 1 { + pvc_fail("binary bytes are not canonical unpadded base64url") } let bytes : Array[Byte] = [] for i = 0; i < chars.length(); i = i + 4 { let a = pvc_b64_value(chars[i]) let b = pvc_b64_value(chars[i + 1]) - let last = i + 4 == chars.length() - let c = if chars[i + 2] == '=' { -2 } else { pvc_b64_value(chars[i + 2]) } - let d = if chars[i + 3] == '=' { -2 } else { pvc_b64_value(chars[i + 3]) } - if a < 0 || - b < 0 || - c == -1 || - d == -1 || - (!last && (c < 0 || d < 0)) || - (c == -2 && d != -2) || - (c == -2 && (b & 15) != 0) || - (d == -2 && c >= 0 && (c & 3) != 0) { - pvc_fail("binary bytes are not canonical padded base64") + let c = if i + 2 < chars.length() { + pvc_b64_value(chars[i + 2]) + } else { + -2 + } + let d = if i + 3 < chars.length() { + pvc_b64_value(chars[i + 3]) + } else { + -2 + } + if a < 0 || b < 0 || c == -1 || d == -1 { + pvc_fail("binary bytes are not canonical unpadded base64url") } bytes.push(((a << 2) | (b >> 4)).to_byte()) if c >= 0 { @@ -713,12 +713,16 @@ fn pvc_base64_decode(value : String) -> Bytes raise { } } } - Bytes::from_array(bytes) + let result = Bytes::from_array(bytes) + if pvc_base64_encode(result) != value { + pvc_fail("binary bytes are not canonical unpadded base64url") + } + result } ///| fn pvc_base64_encode(bytes : Bytes) -> String { - let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" let out = StringBuilder() for i = 0; i < bytes.length(); i = i + 3 { let a = bytes[i].to_int() @@ -728,20 +732,12 @@ fn pvc_base64_encode(bytes : Bytes) -> String { let c = if have_c { bytes[i + 2].to_int() } else { 0 } out.write_char(alphabet.get_char(a >> 2).unwrap()) out.write_char(alphabet.get_char(((a & 3) << 4) | (b >> 4)).unwrap()) - out.write_char( - if have_b { - alphabet.get_char(((b & 15) << 2) | (c >> 6)).unwrap() - } else { - '=' - }, - ) - out.write_char( - if have_c { - alphabet.get_char(c & 63).unwrap() - } else { - '=' - }, - ) + if have_b { + out.write_char(alphabet.get_char(((b & 15) << 2) | (c >> 6)).unwrap()) + } + if have_c { + out.write_char(alphabet.get_char(c & 63).unwrap()) + } } out.to_string() } diff --git a/cli/golem-cli/src/bridge_gen/moonbit/runtime/stream_wbtest.mbt b/cli/golem-cli/src/bridge_gen/moonbit/runtime/stream_wbtest.mbt index 4d97542383..7852ce6f9b 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/runtime/stream_wbtest.mbt +++ b/cli/golem-cli/src/bridge_gen/moonbit/runtime/stream_wbtest.mbt @@ -86,7 +86,7 @@ test "public codec preserves record s64 binary and structural wire shapes" { inspect( json.stringify(), content=( - #|{"count":"-9","data":{"bytes":"AP8=","mimeType":"application/octet-stream"},"choice":{"$option":"some","value":"yes"}} + #|{"count":"-9","data":{"bytes":"AP8","mimeType":"application/octet-stream"},"choice":{"$option":"some","value":"yes"}} ), ) assert_true(codec.decode(json) == value) diff --git a/cli/golem-cli/tests/bridge_gen/moonbit.rs b/cli/golem-cli/tests/bridge_gen/moonbit.rs index a5138cfe85..cf8114ab1e 100644 --- a/cli/golem-cli/tests/bridge_gen/moonbit.rs +++ b/cli/golem-cli/tests/bridge_gen/moonbit.rs @@ -2229,6 +2229,103 @@ fn config_constructors_are_generated() { )); } +#[test] +fn reflection_corpus_drives_generated_runtime_wire_regressions() { + let corpus: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string( + workspace_root() + .unwrap() + .join("test-data/reflection-conformance/v1.json"), + ) + .unwrap(), + ) + .unwrap(); + let case = |id: &str| { + corpus["cases"] + .as_array() + .unwrap() + .iter() + .find(|case| case["id"] == id) + .unwrap() + }; + let binary_expected = case("canonical/binary-mime")["expected"].to_string(); + let invalid_binary = case("errors/binary-noncanonical-base64")["inputs"] + .as_array() + .unwrap() + .iter() + .map(|value| serde_json::to_string(&value.to_string()).unwrap()) + .collect::>() + .join(", "); + let config_expected = case("config/canonical-entry")["expected"].to_string(); + let request_expected = format!( + "{{\"appName\":\"app\",\"envName\":\"env\",\"agentTypeName\":\"ConfigAgent\",\"parameters\":{{\"kind\":\"tuple\",\"value\":{{\"elements\":[]}}}},\"config\":[{config_expected}]}}" + ); + + let mut agent_type = agent( + "ConfigAgent", + "moonbit", + vec![], + vec![method("ping", vec![], None)], + vec![], + AgentMode::Durable, + ); + agent_type.config = vec![local_config(vec!["limits", "maximum"], SchemaType::s64())]; + let pkg = GeneratedPackage::new(agent_type); + let source = r#"test "reflection corpus bridge wire cases" { + let mime_type = "application/octet-stream" + let binary_value = BinaryValue(b"\xfb\xff", Some(mime_type)) + assert_eq( + schema_value_to_json(binary_value).stringify(), + "{\"kind\":\"binary\",\"value\":{\"bytes\":[251,255],\"mimeType\":\"application/octet-stream\"}}", + ) + let binary_codec = public_value_codec( + "{\"root\":{\"kind\":\"binary\",\"value\":{\"restrictions\":{}}}}", + ) + assert_eq(binary_codec.encode(binary_value).stringify(), __BINARY_EXPECTED__) + for input in [__INVALID_BINARY__] { + try binary_codec.decode(@json.parse(input)) catch { + BridgeError(_) => () + _ => fail("expected noncanonical binary JSON to be rejected") + } noraise { + _ => fail("expected noncanonical binary JSON to be rejected") + } + } + let s64_codec = public_value_codec( + "{\"root\":{\"kind\":\"s64\",\"value\":{}}}", + ) + let entry = AgentConfigEntry::{ + path: ["limits", "maximum"], + value: S64Value(9223372036854775807L), + codec: s64_codec, + } + let request = encode_create_agent_request( + "app", + "env", + "ConfigAgent", + TupleValue([]), + None, + [entry], + ) + assert_eq(request.stringify(), __REQUEST_EXPECTED__) +} +"# + .replace( + "__BINARY_EXPECTED__", + &serde_json::to_string(&binary_expected).unwrap(), + ) + .replace("__INVALID_BINARY__", &invalid_binary) + .replace( + "__REQUEST_EXPECTED__", + &serde_json::to_string(&request_expected).unwrap(), + ); + std::fs::write( + pkg.module_dir().join("runtime/reflection_wire_wbtest.mbt"), + source, + ) + .unwrap(); + pkg.test_native(); +} + /// An ephemeral agent must not get a `get_with_config` (no parameter-addressable /// `get`), but still gets the phantom config variants. #[test] From 1b99b22424a17ca7912f504f640e44a82847afef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 18:30:36 +0200 Subject: [PATCH 19/37] test: extend reflection conformance for binary and config --- golem-schema/src/schema/render/json_schema.rs | 2 +- golem-schema/src/schema/render/tests.rs | 34 ++++++++---- .../test/reflection-conformance.test.ts | 11 ++-- .../reflection_conformance_corpus_wbtest.mbt | 2 +- .../reflection_conformance_wbtest.mbt | 33 +++++++++--- .../rust/golem-rust/src/agentic/reflection.rs | 22 +++++--- .../ReflectionConformanceCorpus.scala | 53 +++++++++++++++++++ .../ReflectionConformanceSpec.scala | 13 ++++- .../tests/reflection-conformance.test.ts | 9 +++- test-data/reflection-conformance/v1.json | 27 ++++++++++ 10 files changed, 174 insertions(+), 32 deletions(-) diff --git a/golem-schema/src/schema/render/json_schema.rs b/golem-schema/src/schema/render/json_schema.rs index 57f7a54875..097491a7bc 100644 --- a/golem-schema/src/schema/render/json_schema.rs +++ b/golem-schema/src/schema/render/json_schema.rs @@ -1058,7 +1058,7 @@ fn text_schema(restrictions: &TextRestrictions) -> Map { } fn binary_schema(restrictions: &BinaryRestrictions) -> Map { - // Canonical Binary JSON shape: `{ bytes: base64url-string, mime_type?: string }`. + // Canonical Binary JSON shape: `{ bytes: base64url-string, mimeType?: string }`. // `min_bytes` / `max_bytes` count *raw* bytes; the JSON field is // base64url-no-pad-encoded, so the on-wire string length is // `base64url_no_pad_len(n) = 4*(n/3) + match n%3 { 0=>0, 1=>2, 2=>3 }`. diff --git a/golem-schema/src/schema/render/tests.rs b/golem-schema/src/schema/render/tests.rs index b9e2555812..aa169d7edc 100644 --- a/golem-schema/src/schema/render/tests.rs +++ b/golem-schema/src/schema/render/tests.rs @@ -18,9 +18,10 @@ use super::{ use crate::schema::schema_type::{NumericBound, NumericRestrictions}; use crate::schema::validation::{is_equivalent_cross_graph, validate_graph, validate_value}; use crate::schema::{ - DurationValuePayload, MetadataEnvelope, NamedFieldType, PermissionCardSpec, QuantitySpec, - QuantityValue, QuotaTokenSpec, ResultSpec, SchemaGraph, SchemaType, SchemaTypeDef, SchemaValue, - TextRestrictions, TextValuePayload, TypeId, VariantCaseType, VariantValuePayload, + BinaryRestrictions, DurationValuePayload, MetadataEnvelope, NamedFieldType, PermissionCardSpec, + QuantitySpec, QuantityValue, QuotaTokenSpec, ResultSpec, SchemaGraph, SchemaType, + SchemaTypeDef, SchemaValue, TextRestrictions, TextValuePayload, TypeId, VariantCaseType, + VariantValuePayload, }; use proptest::prelude::*; use serde_json::{Value, json}; @@ -367,6 +368,7 @@ fn conformance_fixture(name: &str) -> SchemaGraph { let root = match name { "s64" => SchemaType::s64(), "u64" => SchemaType::u64(), + "binary" => SchemaType::binary(BinaryRestrictions::default()), "duration" => SchemaType::duration(), "quantity" => SchemaType::quantity(QuantitySpec { base_unit: "m".to_string(), @@ -379,6 +381,10 @@ fn conformance_fixture(name: &str) -> SchemaGraph { field("paths", SchemaType::list(SchemaType::string())), field("ignoreCase", SchemaType::option(SchemaType::bool())), ]), + "config-entry" => SchemaType::record(vec![ + field("path", SchemaType::list(SchemaType::string())), + field("value", SchemaType::s64()), + ]), "constrained-u32" => SchemaType::U32 { restrictions: Some(NumericRestrictions { min: None, @@ -589,14 +595,20 @@ fn reflection_conformance_corpus() { } "reject" => { let graph = conformance_fixture(fixture); - let decoded = from_json_value(&graph, &graph.root, &case["input"]); - let rejected = match fixture { - "constrained-u32" => decoded - .as_ref() - .is_ok_and(|value| validate_value(&graph, &graph.root, value).is_err()), - _ => decoded.is_err(), - }; - assert!(rejected, "{id} was accepted"); + let inputs = case["inputs"] + .as_array() + .cloned() + .unwrap_or_else(|| vec![case["input"].clone()]); + for input in inputs { + let decoded = from_json_value(&graph, &graph.root, &input); + let rejected = match fixture { + "constrained-u32" => decoded + .as_ref() + .is_ok_and(|value| validate_value(&graph, &graph.root, value).is_err()), + _ => decoded.is_err(), + }; + assert!(rejected, "{id} accepted {input}"); + } } "json-schema" => { let graph = conformance_fixture(fixture); diff --git a/sdks/effect/test/reflection-conformance.test.ts b/sdks/effect/test/reflection-conformance.test.ts index 15bb3ada23..741d7fcc08 100644 --- a/sdks/effect/test/reflection-conformance.test.ts +++ b/sdks/effect/test/reflection-conformance.test.ts @@ -9,6 +9,7 @@ interface ConformanceCase { operation: "roundtrip" | "reject" | "json-schema" | "semantic" fixture: string input?: JsonValue + inputs?: JsonValue[] path?: string expected: JsonValue } @@ -103,6 +104,8 @@ function fixture(name: string): SchemaRef { return ref(t.s64()) case "u64": return ref(t.u64()) + case "binary": + return ref(t.binary()) case "duration": return ref(t.duration()) case "quantity": @@ -125,6 +128,8 @@ function fixture(name: string): SchemaRef { field("ignoreCase", t.option(t.bool())), ]), ) + case "config-entry": + return ref(t.record([field("path", t.list(t.string())), field("value", t.s64())])) case "constrained-u32": return ref(t.u32({ max: { tag: "unsigned", val: 10n } })) case "result": @@ -222,9 +227,9 @@ describe("reflection conformance corpus", () => { break } case "reject": - expect(fixture(testCase.fixture).validateJson(testCase.input!).success, testCase.id).toBe( - false, - ) + for (const input of testCase.inputs ?? [testCase.input!]) { + expect(fixture(testCase.fixture).validateJson(input).success, testCase.id).toBe(false) + } break case "json-schema": expectSubset( diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt index c22ea1e05d..fc7eeba8a9 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt @@ -9,4 +9,4 @@ // Generated by scripts/sync-reflection-conformance.mjs. Do not edit. ///| -let reflection_conformance_corpus_json : String = "{\n \"version\": \"1.0.0\",\n \"schemaKinds\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ],\n \"restrictionKinds\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ],\n \"caseIds\": [\n \"canonical/s64-min\",\n \"canonical/s64-max\",\n \"canonical/u64-max\",\n \"canonical/duration-min\",\n \"canonical/duration-max\",\n \"canonical/quantity-wide-mantissa\",\n \"canonical/omitted-options\",\n \"errors/s64-plus\",\n \"errors/s64-leading-zero\",\n \"errors/s64-negative-zero\",\n \"errors/s64-overflow\",\n \"errors/u64-negative\",\n \"errors/u64-overflow\",\n \"errors/wide-wrong-json-type\",\n \"errors/duration-legacy-string\",\n \"errors/quantity-negative-zero\",\n \"json-schema/s64\",\n \"json-schema/u64\",\n \"json-schema/duration-nanoseconds\",\n \"json-schema/quantity-mantissa\",\n \"json-schema/optional-required\",\n \"json-schema/unsupported-leaves\",\n \"schema/all-kinds\",\n \"schema/all-restrictions\",\n \"graph/references-validation-equivalence\",\n \"tool/canonical-input\",\n \"constraints/rejected-value\",\n \"compatibility/result\",\n \"compatibility/custom-error\"\n ],\n \"cases\": [\n {\n \"id\": \"canonical/s64-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"-9223372036854775808\",\n \"expected\": \"-9223372036854775808\"\n },\n {\n \"id\": \"canonical/s64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775807\",\n \"expected\": \"9223372036854775807\"\n },\n {\n \"id\": \"canonical/u64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551615\",\n \"expected\": \"18446744073709551615\"\n },\n {\n \"id\": \"canonical/duration-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"-9223372036854775808\"\n },\n \"expected\": {\n \"nanoseconds\": \"-9223372036854775808\"\n }\n },\n {\n \"id\": \"canonical/duration-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"9223372036854775807\"\n },\n \"expected\": {\n \"nanoseconds\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"canonical/quantity-wide-mantissa\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n }\n },\n {\n \"id\": \"canonical/omitted-options\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"optional-record\",\n \"input\": {},\n \"expected\": {\n \"direct\": null,\n \"referenced\": null\n }\n },\n {\n \"id\": \"errors/s64-plus\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"+1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-leading-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"01\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"-0\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775808\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-negative\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"-1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551616\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/wide-wrong-json-type\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": 1,\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/duration-legacy-string\",\n \"operation\": \"reject\",\n \"fixture\": \"duration\",\n \"input\": \"PT1S\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-0\",\n \"scale\": 0,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"json-schema/s64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/u64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"u64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"uint64\",\n \"pattern\": \"^(?:0|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"0\",\n \"x-golem-maximum\": \"18446744073709551615\"\n }\n },\n {\n \"id\": \"json-schema/duration-nanoseconds\",\n \"operation\": \"json-schema\",\n \"fixture\": \"duration\",\n \"path\": \"/properties/nanoseconds\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/quantity-mantissa\",\n \"operation\": \"json-schema\",\n \"fixture\": \"quantity\",\n \"path\": \"/properties/mantissa\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/optional-required\",\n \"operation\": \"json-schema\",\n \"fixture\": \"optional-record\",\n \"path\": \"/required\",\n \"expected\": []\n },\n {\n \"id\": \"json-schema/unsupported-leaves\",\n \"operation\": \"semantic\",\n \"fixture\": \"unsupported-leaves\",\n \"expected\": {\n \"count\": 5,\n \"schema\": {\n \"not\": {}\n }\n }\n },\n {\n \"id\": \"schema/all-kinds\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-kinds\",\n \"expected\": {\n \"names\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ]\n }\n },\n {\n \"id\": \"schema/all-restrictions\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-restrictions\",\n \"expected\": {\n \"names\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ]\n }\n },\n {\n \"id\": \"graph/references-validation-equivalence\",\n \"operation\": \"semantic\",\n \"fixture\": \"graph\",\n \"expected\": {\n \"valid\": true,\n \"equivalent\": true\n }\n },\n {\n \"id\": \"tool/canonical-input\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"tool-input\",\n \"input\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ]\n },\n \"expected\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ],\n \"ignoreCase\": null\n }\n },\n {\n \"id\": \"constraints/rejected-value\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-u32\",\n \"input\": 11,\n \"expected\": {\n \"kind\": \"constraint-violation\"\n }\n },\n {\n \"id\": \"compatibility/result\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"result\",\n \"input\": {\n \"ok\": \"ready\"\n },\n \"expected\": {\n \"ok\": \"ready\"\n }\n },\n {\n \"id\": \"compatibility/custom-error\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"custom-error\",\n \"input\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n },\n \"expected\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n }\n }\n ]\n}\n" +let reflection_conformance_corpus_json : String = "{\n \"version\": \"1.0.0\",\n \"schemaKinds\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ],\n \"restrictionKinds\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ],\n \"caseIds\": [\n \"canonical/s64-min\",\n \"canonical/s64-max\",\n \"canonical/u64-max\",\n \"canonical/duration-min\",\n \"canonical/duration-max\",\n \"canonical/quantity-wide-mantissa\",\n \"canonical/omitted-options\",\n \"canonical/binary-mime\",\n \"errors/s64-plus\",\n \"errors/s64-leading-zero\",\n \"errors/s64-negative-zero\",\n \"errors/s64-overflow\",\n \"errors/u64-negative\",\n \"errors/u64-overflow\",\n \"errors/wide-wrong-json-type\",\n \"errors/duration-legacy-string\",\n \"errors/quantity-negative-zero\",\n \"errors/binary-noncanonical-base64\",\n \"json-schema/s64\",\n \"json-schema/u64\",\n \"json-schema/duration-nanoseconds\",\n \"json-schema/quantity-mantissa\",\n \"json-schema/optional-required\",\n \"json-schema/unsupported-leaves\",\n \"schema/all-kinds\",\n \"schema/all-restrictions\",\n \"graph/references-validation-equivalence\",\n \"tool/canonical-input\",\n \"config/canonical-entry\",\n \"constraints/rejected-value\",\n \"compatibility/result\",\n \"compatibility/custom-error\"\n ],\n \"cases\": [\n {\n \"id\": \"canonical/s64-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"-9223372036854775808\",\n \"expected\": \"-9223372036854775808\"\n },\n {\n \"id\": \"canonical/s64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775807\",\n \"expected\": \"9223372036854775807\"\n },\n {\n \"id\": \"canonical/u64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551615\",\n \"expected\": \"18446744073709551615\"\n },\n {\n \"id\": \"canonical/duration-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"-9223372036854775808\"\n },\n \"expected\": {\n \"nanoseconds\": \"-9223372036854775808\"\n }\n },\n {\n \"id\": \"canonical/duration-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"9223372036854775807\"\n },\n \"expected\": {\n \"nanoseconds\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"canonical/quantity-wide-mantissa\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n }\n },\n {\n \"id\": \"canonical/omitted-options\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"optional-record\",\n \"input\": {},\n \"expected\": {\n \"direct\": null,\n \"referenced\": null\n }\n },\n {\n \"id\": \"canonical/binary-mime\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"binary\",\n \"input\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n },\n \"expected\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n }\n },\n {\n \"id\": \"errors/s64-plus\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"+1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-leading-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"01\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"-0\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775808\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-negative\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"-1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551616\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/wide-wrong-json-type\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": 1,\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/duration-legacy-string\",\n \"operation\": \"reject\",\n \"fixture\": \"duration\",\n \"input\": \"PT1S\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-0\",\n \"scale\": 0,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/binary-noncanonical-base64\",\n \"operation\": \"reject\",\n \"fixture\": \"binary\",\n \"inputs\": [\n {\n \"bytes\": \"+/8\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8=\",\n \"mimeType\": \"application/octet-stream\"\n }\n ],\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"json-schema/s64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/u64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"u64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"uint64\",\n \"pattern\": \"^(?:0|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"0\",\n \"x-golem-maximum\": \"18446744073709551615\"\n }\n },\n {\n \"id\": \"json-schema/duration-nanoseconds\",\n \"operation\": \"json-schema\",\n \"fixture\": \"duration\",\n \"path\": \"/properties/nanoseconds\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/quantity-mantissa\",\n \"operation\": \"json-schema\",\n \"fixture\": \"quantity\",\n \"path\": \"/properties/mantissa\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/optional-required\",\n \"operation\": \"json-schema\",\n \"fixture\": \"optional-record\",\n \"path\": \"/required\",\n \"expected\": []\n },\n {\n \"id\": \"json-schema/unsupported-leaves\",\n \"operation\": \"semantic\",\n \"fixture\": \"unsupported-leaves\",\n \"expected\": {\n \"count\": 5,\n \"schema\": {\n \"not\": {}\n }\n }\n },\n {\n \"id\": \"schema/all-kinds\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-kinds\",\n \"expected\": {\n \"names\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ]\n }\n },\n {\n \"id\": \"schema/all-restrictions\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-restrictions\",\n \"expected\": {\n \"names\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ]\n }\n },\n {\n \"id\": \"graph/references-validation-equivalence\",\n \"operation\": \"semantic\",\n \"fixture\": \"graph\",\n \"expected\": {\n \"valid\": true,\n \"equivalent\": true\n }\n },\n {\n \"id\": \"tool/canonical-input\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"tool-input\",\n \"input\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ]\n },\n \"expected\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ],\n \"ignoreCase\": null\n }\n },\n {\n \"id\": \"config/canonical-entry\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"config-entry\",\n \"input\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n },\n \"expected\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"constraints/rejected-value\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-u32\",\n \"input\": 11,\n \"expected\": {\n \"kind\": \"constraint-violation\"\n }\n },\n {\n \"id\": \"compatibility/result\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"result\",\n \"input\": {\n \"ok\": \"ready\"\n },\n \"expected\": {\n \"ok\": \"ready\"\n }\n },\n {\n \"id\": \"compatibility/custom-error\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"custom-error\",\n \"input\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n },\n \"expected\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n }\n }\n ]\n}\n" diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt index 67505aba1c..686422ecb4 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt @@ -43,6 +43,10 @@ fn conformance_fixture(name : String) -> SchemaRef raise { let root = match name { "s64" => @model.schema_type(S64(None)) "u64" => @model.schema_type(U64(None)) + "binary" => + @model.schema_type( + Binary({ mime_types: None, min_bytes: None, max_bytes: None, }), + ) "duration" => @model.schema_type(Duration) "quantity" => @model.schema_type( @@ -62,6 +66,16 @@ fn conformance_fixture(name : String) -> SchemaRef raise { ), ]), ) + "config-entry" => + @model.schema_type( + Record([ + corpus_field( + "path", + @model.schema_type(List(@model.schema_type(String))), + ), + corpus_field("value", @model.schema_type(S64(None))), + ]), + ) "constrained-u32" => @model.schema_type( U32(Some({ min: None, max: Some(@types.Unsigned(10UL)), unit: None, })), @@ -257,14 +271,19 @@ test "reflection conformance corpus executes the complete case-ID set" { test_case.get("expected").unwrap(), ) } - "reject" => - try - conformance_fixture(name).pack_json(test_case.get("input").unwrap()) - catch { - _ => () - } noraise { - _ => fail("conformance case was accepted: " + id) + "reject" => { + let inputs = match test_case.get("inputs") { + Some(values) => corpus_array(values) + None => [test_case.get("input").unwrap()] } + for input in inputs { + try conformance_fixture(name).pack_json(input) catch { + _ => () + } noraise { + _ => fail("conformance case was accepted: " + id) + } + } + } "json-schema" => assert_conformance_subset( conformance_at_pointer( diff --git a/sdks/rust/golem-rust/src/agentic/reflection.rs b/sdks/rust/golem-rust/src/agentic/reflection.rs index 3e395bd10e..830d1b5278 100644 --- a/sdks/rust/golem-rust/src/agentic/reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/reflection.rs @@ -1681,9 +1681,9 @@ mod tests { use crate::schema::schema_type::{NumericBound, NumericRestrictions}; use crate::schema::validation::{is_equivalent_cross_graph, validate_graph}; use crate::schema::{ - MetadataEnvelope, NamedFieldType, PermissionCardSpec, QuantitySpec, QuotaTokenSpec, - ResultSpec, SchemaGraph, SchemaType, SchemaTypeDef, SchemaValue, TypeId, VariantCaseType, - VariantValuePayload, + BinaryRestrictions, MetadataEnvelope, NamedFieldType, PermissionCardSpec, QuantitySpec, + QuotaTokenSpec, ResultSpec, SchemaGraph, SchemaType, SchemaTypeDef, SchemaValue, TypeId, + VariantCaseType, VariantValuePayload, }; use serde_json::{Value, json}; use std::collections::HashSet; @@ -1762,6 +1762,7 @@ mod tests { let root = match name { "s64" => SchemaType::s64(), "u64" => SchemaType::u64(), + "binary" => SchemaType::binary(BinaryRestrictions::default()), "duration" => SchemaType::duration(), "quantity" => SchemaType::quantity(QuantitySpec { base_unit: "m".to_string(), @@ -1774,6 +1775,10 @@ mod tests { conformance_field("paths", SchemaType::list(SchemaType::string())), conformance_field("ignoreCase", SchemaType::option(SchemaType::bool())), ]), + "config-entry" => SchemaType::record(vec![ + conformance_field("path", SchemaType::list(SchemaType::string())), + conformance_field("value", SchemaType::s64()), + ]), "constrained-u32" => SchemaType::U32 { restrictions: Some(NumericRestrictions { min: None, @@ -1977,10 +1982,13 @@ mod tests { } "reject" => { let schema = conformance_schema(fixture); - assert!( - schema.pack_json(&case["input"]).is_err(), - "{id} was accepted" - ); + let inputs = case["inputs"] + .as_array() + .cloned() + .unwrap_or_else(|| vec![case["input"].clone()]); + for input in inputs { + assert!(schema.pack_json(&input).is_err(), "{id} accepted {input}"); + } } "json-schema" => { let schema = conformance_schema(fixture); diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala index 3af48619f5..fb1109c613 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala @@ -87,6 +87,7 @@ private[reflection] object ReflectionConformanceCorpus { "canonical/duration-max", "canonical/quantity-wide-mantissa", "canonical/omitted-options", + "canonical/binary-mime", "errors/s64-plus", "errors/s64-leading-zero", "errors/s64-negative-zero", @@ -96,6 +97,7 @@ private[reflection] object ReflectionConformanceCorpus { "errors/wide-wrong-json-type", "errors/duration-legacy-string", "errors/quantity-negative-zero", + "errors/binary-noncanonical-base64", "json-schema/s64", "json-schema/u64", "json-schema/duration-nanoseconds", @@ -106,6 +108,7 @@ private[reflection] object ReflectionConformanceCorpus { "schema/all-restrictions", "graph/references-validation-equivalence", "tool/canonical-input", + "config/canonical-entry", "constraints/rejected-value", "compatibility/result", "compatibility/custom-error" @@ -179,6 +182,19 @@ private[reflection] object ReflectionConformanceCorpus { "referenced": null } }, + { + "id": "canonical/binary-mime", + "operation": "roundtrip", + "fixture": "binary", + "input": { + "bytes": "-_8", + "mimeType": "application/octet-stream" + }, + "expected": { + "bytes": "-_8", + "mimeType": "application/octet-stream" + } + }, { "id": "errors/s64-plus", "operation": "reject", @@ -264,6 +280,24 @@ private[reflection] object ReflectionConformanceCorpus { "kind": "invalid-json" } }, + { + "id": "errors/binary-noncanonical-base64", + "operation": "reject", + "fixture": "binary", + "inputs": [ + { + "bytes": "+/8", + "mimeType": "application/octet-stream" + }, + { + "bytes": "-_8=", + "mimeType": "application/octet-stream" + } + ], + "expected": { + "kind": "invalid-json" + } + }, { "id": "json-schema/s64", "operation": "json-schema", @@ -442,6 +476,25 @@ private[reflection] object ReflectionConformanceCorpus { "ignoreCase": null } }, + { + "id": "config/canonical-entry", + "operation": "roundtrip", + "fixture": "config-entry", + "input": { + "path": [ + "limits", + "maximum" + ], + "value": "9223372036854775807" + }, + "expected": { + "path": [ + "limits", + "maximum" + ], + "value": "9223372036854775807" + } + }, { "id": "constraints/rejected-value", "operation": "reject", diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala index ccd829ab4b..9cceaa2f6a 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala @@ -45,6 +45,7 @@ object ReflectionConformanceSpec extends ZIOSpecDefault { val root = name match { case "s64" => SchemaType(S64Type()) case "u64" => SchemaType(U64Type()) + case "binary" => SchemaType(BinaryType(BinaryRestrictions.empty)) case "duration" => SchemaType(DurationType) case "quantity" => SchemaType(QuantityType(QuantitySpec("m", Nil, None, None))) case "tool-input" => @@ -57,6 +58,15 @@ object ReflectionConformanceSpec extends ZIOSpecDefault { ) ) ) + case "config-entry" => + SchemaType( + RecordType( + List( + field("path", SchemaType(ListType(SchemaType(StringType)))), + field("value", SchemaType(S64Type())) + ) + ) + ) case "constrained-u32" => SchemaType(U32Type(Some(NumericRestrictions(max = Some(NumericBound.Unsigned(10)))))) case "result" => @@ -247,7 +257,8 @@ object ReflectionConformanceSpec extends ZIOSpecDefault { schema.packJson(testCase("input")).fold(error => throw new AssertionError(s"$id: $error"), identity) Predef.assert(schema.unpackJson(packed) == Right(testCase("expected")), id) case "reject" => - Predef.assert(fixture(name).validateJson(testCase("input")).isLeft, s"$id was accepted") + val inputs = testCase.get("inputs").map(elements).getOrElse(List(testCase("input"))) + inputs.foreach(input => Predef.assert(fixture(name).validateJson(input).isLeft, s"$id accepted $input")) case "json-schema" => assertSubset( atPointer(fixture(name).toJsonSchema(false), string(testCase("path"))), diff --git a/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts index 6280225338..1fa7b3034a 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts @@ -22,6 +22,7 @@ interface ConformanceCase { operation: 'roundtrip' | 'reject' | 'json-schema' | 'semantic'; fixture: string; input?: JsonValue; + inputs?: JsonValue[]; path?: string; expected: JsonValue; } @@ -118,6 +119,8 @@ function fixture(name: string): SchemaRef { return schema(t.s64()); case 'u64': return schema(t.u64()); + case 'binary': + return schema(schemaType({ tag: 'binary', restrictions: {} })); case 'duration': return schema(t.duration()); case 'quantity': @@ -140,6 +143,8 @@ function fixture(name: string): SchemaRef { field('ignoreCase', t.option(t.bool())), ]), ); + case 'config-entry': + return schema(t.record([field('path', t.list(t.string())), field('value', t.s64())])); case 'constrained-u32': return schema(t.u32({ max: { tag: 'unsigned', val: 10n } })); case 'result': @@ -238,7 +243,9 @@ describe('reflection conformance corpus', () => { } case 'reject': { const ref = fixture(testCase.fixture); - expect(ref.validateJson(testCase.input!).success, testCase.id).toBe(false); + for (const input of testCase.inputs ?? [testCase.input!]) { + expect(ref.validateJson(input).success, testCase.id).toBe(false); + } break; } case 'json-schema': diff --git a/test-data/reflection-conformance/v1.json b/test-data/reflection-conformance/v1.json index 45e8e83f11..f61035c4b4 100644 --- a/test-data/reflection-conformance/v1.json +++ b/test-data/reflection-conformance/v1.json @@ -24,6 +24,7 @@ "canonical/duration-max", "canonical/quantity-wide-mantissa", "canonical/omitted-options", + "canonical/binary-mime", "errors/s64-plus", "errors/s64-leading-zero", "errors/s64-negative-zero", @@ -33,6 +34,7 @@ "errors/wide-wrong-json-type", "errors/duration-legacy-string", "errors/quantity-negative-zero", + "errors/binary-noncanonical-base64", "json-schema/s64", "json-schema/u64", "json-schema/duration-nanoseconds", @@ -43,6 +45,7 @@ "schema/all-restrictions", "graph/references-validation-equivalence", "tool/canonical-input", + "config/canonical-entry", "constraints/rejected-value", "compatibility/result", "compatibility/custom-error" @@ -97,6 +100,13 @@ "input": {}, "expected": { "direct": null, "referenced": null } }, + { + "id": "canonical/binary-mime", + "operation": "roundtrip", + "fixture": "binary", + "input": { "bytes": "-_8", "mimeType": "application/octet-stream" }, + "expected": { "bytes": "-_8", "mimeType": "application/octet-stream" } + }, { "id": "errors/s64-plus", "operation": "reject", @@ -160,6 +170,16 @@ "input": { "mantissa": "-0", "scale": 0, "unit": "m" }, "expected": { "kind": "invalid-json" } }, + { + "id": "errors/binary-noncanonical-base64", + "operation": "reject", + "fixture": "binary", + "inputs": [ + { "bytes": "+/8", "mimeType": "application/octet-stream" }, + { "bytes": "-_8=", "mimeType": "application/octet-stream" } + ], + "expected": { "kind": "invalid-json" } + }, { "id": "json-schema/s64", "operation": "json-schema", @@ -268,6 +288,13 @@ "input": { "pattern": "*.rs", "paths": ["src", "tests"] }, "expected": { "pattern": "*.rs", "paths": ["src", "tests"], "ignoreCase": null } }, + { + "id": "config/canonical-entry", + "operation": "roundtrip", + "fixture": "config-entry", + "input": { "path": ["limits", "maximum"], "value": "9223372036854775807" }, + "expected": { "path": ["limits", "maximum"], "value": "9223372036854775807" } + }, { "id": "constraints/rejected-value", "operation": "reject", From 2d662230fa9d256224a48ab7b08ac8fbdd1917b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 18:31:17 +0200 Subject: [PATCH 20/37] docs: preserve code-first RPC terminology --- docs/src/content/next/develop/rpc.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/next/develop/rpc.mdx b/docs/src/content/next/develop/rpc.mdx index 55c4db54e2..2099c1b500 100644 --- a/docs/src/content/next/develop/rpc.mdx +++ b/docs/src/content/next/develop/rpc.mdx @@ -4,7 +4,7 @@ import { Tabs } from "nextra/components" See the [Agent to Agent communication page](/next/concepts/agent-to-agent-communication) for a general overview of how agents can invoke each other in Golem. -## Normal RPC +## Code-first approach When defining an _agent_ in code, this automatically defines a client interface for calling remote agents as well. This way an agent of an agent type can create other agents and invoke methods on them. The target agent can be of any agent type available for the caller agent in code. From dde30c14892d22c7e840f5d5c8a77096e1a641ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 19:39:53 +0200 Subject: [PATCH 21/37] moonbit: fix streaming fixture after main merge --- .../golem_sdk/schema_model/pkg.generated.mbti | 10 ++++++ .../golem_sdk_tools/lib/pkg.generated.mbti | 31 ++----------------- .../tool-streaming-moonbit/component/moon.pkg | 20 +++++++----- .../component/tool_streaming.mbt | 4 +++ 4 files changed, 29 insertions(+), 36 deletions(-) diff --git a/sdks/moonbit/golem_sdk/schema_model/pkg.generated.mbti b/sdks/moonbit/golem_sdk/schema_model/pkg.generated.mbti index a2fe43b737..db782e9564 100644 --- a/sdks/moonbit/golem_sdk/schema_model/pkg.generated.mbti +++ b/sdks/moonbit/golem_sdk/schema_model/pkg.generated.mbti @@ -24,8 +24,12 @@ pub fn schema_fingerprint_v1(SchemaGraph, SchemaType?) -> SchemaFingerprintV1 ra pub fn schema_graph_from_wit(@types.SchemaGraph) -> SchemaGraph raise SchemaModelError +pub fn schema_graph_roots_from_wit(@types.SchemaGraph) -> DecodedSchemaGraph raise SchemaModelError + pub fn schema_graph_to_wit(SchemaGraph) -> @types.SchemaGraph raise SchemaModelError +pub fn schema_root_is_option(SchemaGraph) -> Bool + pub fn schema_type(SchemaTypeBody, metadata? : @types.MetadataEnvelope) -> SchemaType pub fn schema_value_from_wit(@types.SchemaValueTree, SchemaValueResourceDrops) -> SchemaValue raise SchemaModelError @@ -86,6 +90,11 @@ pub suberror SchemaModelError { } derive(Eq, @debug.Debug) // Types and methods +pub(all) struct DecodedSchemaGraph { + graph : SchemaGraph + roots : Array[SchemaType] +} derive(Eq, @debug.Debug) + pub(all) struct EncodedAgentGraph { graph : @types.SchemaGraph root_indices : Array[Int] @@ -95,6 +104,7 @@ pub struct GraphDecoder { nodes : Array[@types.SchemaTypeNode] wit_defs : Array[@types.SchemaTypeDef] on_path : Array[Bool] + decoded : Array[SchemaType?] } pub fn GraphDecoder::decode_defs(Self) -> Array[SchemaTypeDef] raise SchemaModelError pub fn GraphDecoder::decode_type_at(Self, Int) -> SchemaType raise SchemaModelError diff --git a/sdks/moonbit/golem_sdk_tools/lib/pkg.generated.mbti b/sdks/moonbit/golem_sdk_tools/lib/pkg.generated.mbti index f96225e650..a71115738b 100644 --- a/sdks/moonbit/golem_sdk_tools/lib/pkg.generated.mbti +++ b/sdks/moonbit/golem_sdk_tools/lib/pkg.generated.mbti @@ -8,8 +8,6 @@ import { } // Values -pub fn component_role_marker(ComponentRole) -> String - pub fn component_target_dir(String, String) -> String pub fn config_has_overridable_fields(ConfigTypeInfo, Map[String, ConfigTypeInfo]) -> Bool @@ -24,10 +22,6 @@ pub fn encode_qualified(String, String) -> ParamType pub fn ensure_pkg_import(String, String, String) -> String raise ComponentRoleError -pub fn ensure_role_gen_import(String, String) -> String raise ComponentRoleError - -pub fn ensure_role_tool_import(String, String) -> String raise ComponentRoleError - pub fn find_config_param(Array[(String, ParamType)]) -> (String, String, Int)? pub fn find_principal_index(Array[(String, ParamType)]) -> Int? @@ -48,7 +42,7 @@ pub fn generate_reexports(Array[FnSignature], String) -> @list.List[@syntax.Impl pub fn generate_tool_client_code(Array[ToolInfo]) -> @list.List[@syntax.Impl] raise ToolEmitError -pub fn generate_tool_middleware_registration(Array[ToolMiddlewareInfo], Array[ToolInfo], ComponentRole) -> @list.List[@syntax.Impl] raise ToolMiddlewareEmitError +pub fn generate_tool_middleware_registration(Array[ToolMiddlewareInfo], Array[ToolInfo]) -> @list.List[@syntax.Impl] raise ToolMiddlewareEmitError pub fn generate_tool_registration(Array[ToolInfo]) -> @list.List[@syntax.Impl] raise ToolEmitError @@ -72,10 +66,6 @@ pub fn parse_agents(Array[(String, String)]) -> Array[AgentInfo] raise AgentPars pub fn parse_agents_command_args(Array[String]) -> AgentsCommandOptions raise ComponentRoleError -pub fn parse_component_role(String) -> ComponentRole raise ComponentRoleError - -pub fn parse_component_role_marker(String) -> ComponentRole raise ComponentRoleError - pub fn parse_config_types(Array[(String, String)], package_path? : String) -> Array[ConfigTypeInfo] raise AgentParseError pub fn parse_duration_seconds(String) -> UInt64 raise AgentParseError @@ -100,9 +90,7 @@ pub fn parse_value_types(Array[(String, String)]) -> Array[ValueTypeInfo] raise pub fn raise_component_role_error(String) -> Unit raise ComponentRoleError -pub fn reconcile_generated_pkg_imports(String, Array[(String, String)], forbid_ambient_tool? : Bool) -> String raise ComponentRoleError - -pub fn reconcile_pure_role_pkg_imports(String) -> String raise ComponentRoleError +pub fn reconcile_generated_pkg_imports(String, Array[(String, String)]) -> String raise ComponentRoleError pub fn rpc_function_name(String, String) -> String @@ -118,8 +106,6 @@ pub fn unwrap_secret_type(ParamType) -> ParamType? pub fn update_pkg_link(String, Array[String]) -> String -pub fn validate_component_role(ComponentRole, Int, Bool) -> Unit raise ComponentRoleError - pub fn validate_config_types(Array[AgentInfo], Array[ConfigTypeInfo], agent_package? : String) -> Unit raise AgentParseError pub fn validate_http_routes(Array[AgentInfo]) -> Unit raise AgentParseError @@ -190,19 +176,7 @@ pub(all) enum AgentSnapshotting { pub(all) struct AgentsCommandOptions { project_root : String component_dir : String - role : ComponentRole -} derive(Eq, @debug.Debug) - -pub(all) enum ComponentRole { - Ordinary - ToolMiddleware - Combined } derive(Eq, @debug.Debug) -pub fn ComponentRole::allows_agent_registration(Self) -> Bool -pub fn ComponentRole::allows_tool_middleware_registration(Self) -> Bool -pub fn ComponentRole::allows_tool_registration(Self) -> Bool -pub fn ComponentRole::gen_package(Self) -> String -pub fn ComponentRole::name(Self) -> String pub(all) struct ConfigTypeInfo { name : String @@ -304,7 +278,6 @@ pub(all) struct ReadOnlyInfo { pub(all) struct ReexportsCommandOptions { sdk_path : String target_dir : String - role : ComponentRole } derive(Eq, @debug.Debug) pub(all) enum SystemVariableKind { diff --git a/test-components/tool-streaming-moonbit/component/moon.pkg b/test-components/tool-streaming-moonbit/component/moon.pkg index db0f1abe4f..6ff30ff412 100644 --- a/test-components/tool-streaming-moonbit/component/moon.pkg +++ b/test-components/tool-streaming-moonbit/component/moon.pkg @@ -1,14 +1,14 @@ import { "golemcloud/golem_sdk/async-core" @asyncCore, - "golemcloud/golem_sdk/gen" @gen, - "golemcloud/golem_sdk/tool" @tool, // Generated by golem_sdk_tools - "golemcloud/golem_sdk/schema" @schema, // Generated by golem_sdk_tools + "golemcloud/golem_sdk/gen", + "golemcloud/golem_sdk/tool", // Generated by golem_sdk_tools + "golemcloud/golem_sdk/schema", // Generated by golem_sdk_tools "golemcloud/golem_sdk/schema_model" @model, // Generated by golem_sdk_tools - "golemcloud/golem_sdk/interface/golem/core/types" @types, // Generated by golem_sdk_tools + "golemcloud/golem_sdk/interface/golem/core/types", // Generated by golem_sdk_tools "golemcloud/golem_sdk/interface/golem/tool/common" @toolCommon, // Generated by golem_sdk_tools - "golemcloud/golem_sdk/agents" @agents, // Generated by golem_sdk_tools - "golemcloud/golem_sdk/interface/golem/agent/common" @common, // Generated by golem_sdk_tools - "golemcloud/golem_sdk/rpc" @rpc, // Generated by golem_sdk_tools + "golemcloud/golem_sdk/agents", // Generated by golem_sdk_tools + "golemcloud/golem_sdk/interface/golem/agent/common", // Generated by golem_sdk_tools + "golemcloud/golem_sdk/rpc", // Generated by golem_sdk_tools "golemcloud/golem_sdk/interface/golem/agent/host" @agentHost, // Generated by golem_sdk_tools "golemcloud/golem_sdk/interface/wasi/clocks/system-clock" @systemClock, // Generated by golem_sdk_tools } @@ -26,15 +26,21 @@ options( "wasmExportAsyncInitialize:[callback][async-lift]golem:agent/guest@2.0.0#initialize", "wasmExportAsyncInvokeGolemAgentGuest:[callback][async-lift]golem:agent/guest@2.0.0#invoke", "wasmExportAsyncInvokeGolemToolGuest:[callback][async-lift]golem:tool/guest@0.1.0#invoke", + "wasmExportAsyncInvokeToolMiddleware:[callback][async-lift]golem:tool/tool-middleware-guest@0.1.0#invoke-tool-middleware", "wasmExportAsyncLoad:[callback][async-lift]golem:api/load-snapshot@1.5.0#load", "wasmExportAsyncSave:[callback][async-lift]golem:api/save-snapshot@1.5.0#save", "wasmExportDiscoverAgentTypes:golem:agent/guest@2.0.0#discover-agent-types", "wasmExportDiscoverAgentTypesPostReturn:cabi_post_golem:agent/guest@2.0.0#discover-agent-types", + "wasmExportDiscoverToolMiddlewares:golem:tool/tool-middleware-guest@0.1.0#discover-tool-middlewares", + "wasmExportDiscoverToolMiddlewaresPostReturn:cabi_post_golem:tool/tool-middleware-guest@0.1.0#discover-tool-middlewares", "wasmExportGetDefinition:golem:agent/guest@2.0.0#get-definition", "wasmExportGetDefinitionPostReturn:cabi_post_golem:agent/guest@2.0.0#get-definition", + "wasmExportGetToolMiddleware:golem:tool/tool-middleware-guest@0.1.0#get-tool-middleware", + "wasmExportGetToolMiddlewarePostReturn:cabi_post_golem:tool/tool-middleware-guest@0.1.0#get-tool-middleware", "wasmExportInitialize:[async-lift]golem:agent/guest@2.0.0#initialize", "wasmExportInvokeGolemAgentGuest:[async-lift]golem:agent/guest@2.0.0#invoke", "wasmExportInvokeGolemToolGuest:[async-lift]golem:tool/guest@0.1.0#invoke", + "wasmExportInvokeToolMiddleware:[async-lift]golem:tool/tool-middleware-guest@0.1.0#invoke-tool-middleware", "wasmExportDiscoverTools:golem:tool/guest@0.1.0#discover-tools", "wasmExportDiscoverToolsPostReturn:cabi_post_golem:tool/guest@0.1.0#discover-tools", "wasmExportGetTool:golem:tool/guest@0.1.0#get-tool", diff --git a/test-components/tool-streaming-moonbit/component/tool_streaming.mbt b/test-components/tool-streaming-moonbit/component/tool_streaming.mbt index b38b427fba..5ef0010262 100644 --- a/test-components/tool-streaming-moonbit/component/tool_streaming.mbt +++ b/test-components/tool-streaming-moonbit/component/tool_streaming.mbt @@ -175,7 +175,11 @@ pub async fn MoonBitToolStreamingCaller::declared_error_completion( let result_terminal = match invocation.get() { Err(@tool.ToolError::Tool(Expected(message))) => "declared:" + message Err(@tool.ToolError::Rpc(_)) => "unexpected:rpc" + Err(@tool.ToolError::RemoteTool(_)) => "unexpected:remote-tool" Err(@tool.ToolError::UnknownToolError(name, _)) => "unexpected:" + name + Err(@tool.ToolError::InvalidInput(_)) => "unexpected:invalid-input" + Err(@tool.ToolError::MalformedRemoteOutput(_)) => + "unexpected:malformed-output" Ok(_) => "ok" } stdout_terminal + ":" + result_terminal From 3c778bf2eb0a9a1448d83af0ec1ae17587d99ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 23 Sep 2026 20:36:31 +0200 Subject: [PATCH 22/37] ci: deliver CLI test binaries as artifact --- .github/workflows/ci.yaml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 81a34f8f33..16203e1085 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -278,11 +278,14 @@ jobs: cp target/dev-release-ci/golem target/dev-release-ci/golem-cli tmp/golem-cli-test-binaries/ find tmp/golem-cli-test-binaries -type f | sort timeout-minutes: 2 - - name: Save CLI test binaries to cache - uses: actions/cache/save@v5 + - name: Upload CLI test binaries + uses: actions/upload-artifact@v6 with: path: tmp/golem-cli-test-binaries - key: golem-cli-test-binaries-${{ github.run_id }} + name: golem-cli-test-binaries + if-no-files-found: error + overwrite: true + retention-days: 1 it: env: @@ -488,12 +491,11 @@ jobs: run-id: ${{ github.run_id }} copy-to-target: 'true' - - name: Restore CLI test binaries - uses: actions/cache/restore@v5 + - name: Download CLI test binaries + uses: actions/download-artifact@v8 with: path: tmp/golem-cli-test-binaries - key: golem-cli-test-binaries-${{ github.run_id }} - fail-on-cache-miss: true + name: golem-cli-test-binaries - name: Install CLI test binaries run: | mkdir -pv target/dev-release-ci @@ -1074,12 +1076,11 @@ jobs: rust-targets: 'wasm32-wasip1 wasm32-wasip2' install-cargo-make: 'false' install-cargo-binstall: 'true' - - name: Restore CLI test binaries - uses: actions/cache/restore@v5 + - name: Download CLI test binaries + uses: actions/download-artifact@v8 with: path: tmp/golem-cli-test-binaries - key: golem-cli-test-binaries-${{ github.run_id }} - fail-on-cache-miss: true + name: golem-cli-test-binaries - name: Install golem-cli run: chmod +x tmp/golem-cli-test-binaries/golem-cli From 2524d10c6e3b29293dcf8278975c491fa459bfde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 24 Sep 2026 09:55:00 +0200 Subject: [PATCH 23/37] test: use protocol base64 for MoonBit stream fixture --- cli/golem-cli/tests/bridge_gen/moonbit.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cli/golem-cli/tests/bridge_gen/moonbit.rs b/cli/golem-cli/tests/bridge_gen/moonbit.rs index cf8114ab1e..64f4c51356 100644 --- a/cli/golem-cli/tests/bridge_gen/moonbit.rs +++ b/cli/golem-cli/tests/bridge_gen/moonbit.rs @@ -2619,7 +2619,11 @@ fn external_recursive_stream_client_compiles() { std::fs::write(&mod_path, serde_json::to_string_pretty(&module).unwrap()).unwrap(); let manifest_path = pkg.module_dir().join("runtime/moon.pkg"); let mut manifest = std::fs::read_to_string(&manifest_path).unwrap(); - manifest = manifest.replacen("import {", "import {\n \"moonbitlang/x/fs\" @fs,", 1); + manifest = manifest.replacen( + "import {", + "import {\n \"moonbitlang/core/encoding/base64\",\n \"moonbitlang/x/fs\" @fs,", + 1, + ); std::fs::write(&manifest_path, manifest).unwrap(); std::fs::write( pkg.module_dir().join("runtime/frozen_fixture_wbtest.mbt"), @@ -2636,7 +2640,7 @@ test "binary codec matches every frozen public v1 frame" { let pair = payload_hex.substring(start=i * 2, end=i * 2 + 2) @string.parse_int(pair[:], base=16).to_byte() }) - let actual = pvc_base64_encode(encode_binary_envelope(metadata, payload)) + let actual = @base64.encode(encode_binary_envelope(metadata, payload), padding=true) assert_true(actual == expect_string(get_field(object, "frameBase64"))) } } From fd2e5fab14b80297c3336cc09da9ae91175e947b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 24 Sep 2026 11:53:48 +0200 Subject: [PATCH 24/37] fix: align public bridge JSON encoding --- cli/golem-cli/src/bridge_gen/rust/mod.rs | 10 ++-- .../src/bridge_gen/typescript/mod.rs | 21 ++++--- cli/golem-cli/tests/bridge_gen/rust.rs | 45 ++++++++++++++- cli/golem-cli/tests/bridge_gen/typescript.rs | 57 +++++++++++++++++++ .../fixtures/stream-session-v1/malformed.json | 10 ++-- .../stream-session-v1/schema-values.json | 2 +- golem-common/src/schema/public_json.rs | 23 +++++--- sdks/ts/packages/golem-ts-bridge/src/index.ts | 35 +++++------- .../golem-ts-bridge/test/runtime.test.mjs | 18 ++++-- 9 files changed, 165 insertions(+), 56 deletions(-) diff --git a/cli/golem-cli/src/bridge_gen/rust/mod.rs b/cli/golem-cli/src/bridge_gen/rust/mod.rs index 66ef43bdca..cd7a8de49b 100644 --- a/cli/golem-cli/src/bridge_gen/rust/mod.rs +++ b/cli/golem-cli/src/bridge_gen/rust/mod.rs @@ -443,14 +443,14 @@ impl RustBridgeGenerator { let __config_graph: crate::__golem_bridge_runtime::schema::SchemaGraph = serde_json::from_str(#config_graph_json) .map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to load config schema: {__e}") })?; + let __config_json = golem_client::invocation_session::encode_generated_streamless_value( + &__config_graph, + &__config_value, + ).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to encode public config value: {__e}") })?; session_config.push(golem_common::model::invocation_session_public::PublicConfigEntry { path: vec![#(#path_segments),*], - value: golem_client::invocation_session::encode_generated_streamless_value( - &__config_graph, - &__config_value, - ).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to encode public config value: {__e}") })?, + value: __config_json.clone(), }); - let __config_json = serde_json::to_value(&__config_value).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to serialize config value: {__e}") })?; agent_config.push(golem_client::model::AgentConfigEntryDto { path: vec![#(#path_segments),*], value: __config_json.into(), diff --git a/cli/golem-cli/src/bridge_gen/typescript/mod.rs b/cli/golem-cli/src/bridge_gen/typescript/mod.rs index 4c67698f0d..42b5607085 100644 --- a/cli/golem-cli/src/bridge_gen/typescript/mod.rs +++ b/cli/golem-cli/src/bridge_gen/typescript/mod.rs @@ -1644,21 +1644,20 @@ impl TypeScriptBridgeGenerator { } for (config, param_name) in local_configs.iter().zip(&names.config_parameters) { let path = serde_json::to_string(&config.path)?; - let encoded_value = self.encode_schema_value(param_name, &config.value_type)?; + let public_value = self.encode_public_value_with_stream( + param_name, + &config.value_type, + "((_value: any) => { throw new Error('configuration streams are unsupported'); })", + )?; + let public_value = + self.validate_public_value(&public_value, &config.value_type, "none"); writer.write_line(format!("if ({param_name} !== undefined) {{")); writer.indent(); writer.write_line(format!( - "{}.push({{ path: {path}, value: {encoded_value} }});", + "{}.push({{ path: {path}, value: {public_value} }});", names.agent_config )); if self.has_external_streams() { - let public_value = self.encode_public_value_with_stream( - param_name, - &config.value_type, - "((_value: any) => { throw new Error('configuration streams are unsupported'); })", - )?; - let public_value = - self.validate_public_value(&public_value, &config.value_type, "none"); writer.write_line(format!( "{}.push({{ path: {path}, value: {public_value} }});", names.public_config @@ -2013,7 +2012,7 @@ impl TypeScriptBridgeGenerator { ) } SchemaType::Binary { .. } => format!( - "({{ bytes: Buffer.from(({value}).bytes).toString('base64'), ...(({value}).mimeType !== undefined ? {{ mimeType: ({value}).mimeType }} : {{}}) }})" + "({{ bytes: Buffer.from(({value}).bytes).toString('base64url'), ...(({value}).mimeType !== undefined ? {{ mimeType: ({value}).mimeType }} : {{}}) }})" ), SchemaType::Text { .. } => value.to_string(), SchemaType::Map { @@ -2163,7 +2162,7 @@ impl TypeScriptBridgeGenerator { self.decode_public_value(&format!("({value} as any).value"), inner)? ), SchemaType::Binary { .. } => format!( - "({{ bytes: Uint8Array.from(Buffer.from(({value} as any).bytes, 'base64')), mimeType: ({value} as any).mimeType }})" + "({{ bytes: Uint8Array.from(Buffer.from(({value} as any).bytes, 'base64url')), mimeType: ({value} as any).mimeType }})" ), SchemaType::Duration { .. } => format!("BigInt(({value} as any).nanoseconds)"), SchemaType::Quantity { .. } => format!( diff --git a/cli/golem-cli/tests/bridge_gen/rust.rs b/cli/golem-cli/tests/bridge_gen/rust.rs index 5ea678f96d..867f1bd7bb 100644 --- a/cli/golem-cli/tests/bridge_gen/rust.rs +++ b/cli/golem-cli/tests/bridge_gen/rust.rs @@ -30,7 +30,9 @@ use golem_common::schema::agent::{ }; use golem_common::schema::graph::{SchemaGraph, SchemaTypeDef}; use golem_common::schema::metadata::TypeId; -use golem_common::schema::schema_type::{BinaryRestrictions, TextRestrictions}; +use golem_common::schema::schema_type::{ + BinaryRestrictions, NumericBound, NumericRestrictions, TextRestrictions, +}; use golem_common::schema::tool::{ BoolFlagShape, CommandBody, CommandIndex, CommandNode, CommandTree, Doc, ErrorCase, ErrorKind, FlagShape, FlagSpec, Globals, OptionShape, OptionSpec, Positional, Positionals, @@ -350,6 +352,47 @@ fn bridge_rust_ephemeral_agent_skips_non_phantom_constructors() { )); } +#[test] +fn bridge_rust_external_rest_config_uses_schema_guided_public_json() { + let dir = TempDir::new().unwrap(); + let target_dir = Utf8Path::from_path(dir.path()).unwrap(); + let mut agent_type = agent( + "ConfigAgent", + "rust", + vec![], + vec![], + vec![], + AgentMode::Durable, + ); + agent_type.config = vec![local_config( + vec!["limits", "maximum"], + SchemaType::S64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::Signed(-9_007_199_254_740_993)), + max: Some(NumericBound::Signed(9_007_199_254_740_993)), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + )]; + let package_dir = target_dir.join(bridge_client_directory_name( + &agent_type.type_name, + BridgeMode::External, + )); + RustBridgeGenerator::new(agent_type, &package_dir, true) + .unwrap() + .generate() + .unwrap(); + + let source = std::fs::read_to_string(package_dir.join("src/lib.rs")).unwrap(); + assert!(source.contains( + "let __config_json = golem_client::invocation_session::encode_generated_streamless_value(" + )); + assert!(source.contains("value: __config_json.clone()")); + assert!(source.contains("value: __config_json.into()")); + assert!(!source.contains("serde_json::to_value(&__config_value)")); +} + #[test] fn bridge_rust_ephemeral_metadata_wrapper_does_not_collide_with_schema_type() { let dir = TempDir::new().unwrap(); diff --git a/cli/golem-cli/tests/bridge_gen/typescript.rs b/cli/golem-cli/tests/bridge_gen/typescript.rs index 3205ffc704..a2a4caf084 100644 --- a/cli/golem-cli/tests/bridge_gen/typescript.rs +++ b/cli/golem-cli/tests/bridge_gen/typescript.rs @@ -304,6 +304,48 @@ fn static_and_instance_agent_methods_can_share_names() { } } +#[test] +fn external_rest_config_uses_schema_guided_public_json() { + let dir = TempDir::new().unwrap(); + let target = Utf8Path::from_path(dir.path()).unwrap(); + let mut agent_type = agent( + "ConfigAgent", + "typescript", + vec![], + vec![], + vec![], + AgentMode::Durable, + ); + agent_type.config = vec![local_config(vec!["limits", "maximum"], SchemaType::u64())]; + let package_dir = target.join("config-agent-client"); + TypeScriptBridgeGenerator::new_with_mode( + agent_type, + &package_dir, + true, + TypeScriptBridgeMode::ExternalRest, + ) + .unwrap() + .generate() + .unwrap(); + + let source = std::fs::read_to_string(package_dir.join("config-agent-client.ts")).unwrap(); + let config_lines = source + .lines() + .filter(|line| line.contains(".push({ path: [\"limits\",\"maximum\"]")) + .collect::>(); + assert!(!config_lines.is_empty(), "missing generated config entry"); + for line in config_lines { + assert!( + line.contains(".toString()"), + "config is not public JSON: {line}" + ); + assert!( + !line.contains("kind:"), + "config is tagged schema JSON: {line}" + ); + } +} + #[test] fn guest_agent_runtime_import_alias_does_not_collide_with_agent_class() { let dir = TempDir::new().unwrap(); @@ -1047,6 +1089,8 @@ fn external_streaming_generation_compiles_recursive_streams() { .unwrap(); assert!(source.contains("createStreamingRemoteMethod")); assert!(source.contains("AgentStream")); + assert!(source.contains("toString('base64url')")); + assert!(source.contains("'base64url'")); assert!(source.contains("AgentStream")); assert!(source.contains(": bigint;")); assert!(source.contains("amount: base.QuantityValue")); @@ -1057,6 +1101,19 @@ fn external_streaming_generation_compiles_recursive_streams() { assert!(source.contains("\"stable\"")); assert!(source.contains("config: this.publicConfig")); assert!(source.contains("path: [\"limits\",\"maximum\"]")); + for line in source + .lines() + .filter(|line| line.contains(".push({ path: [\"limits\",\"maximum\"]")) + { + assert!( + line.contains(".toString()"), + "config is not public JSON: {line}" + ); + assert!( + !line.contains("kind:"), + "config is tagged schema JSON: {line}" + ); + } assert!(source.contains("val: 9007199254740993n")); assert!(source.contains("val: 18446744073709551615n")); assert!(!source.contains("triggerExchange")); diff --git a/golem-client/tests/fixtures/stream-session-v1/malformed.json b/golem-client/tests/fixtures/stream-session-v1/malformed.json index 688073330d..a84628e9ff 100644 --- a/golem-client/tests/fixtures/stream-session-v1/malformed.json +++ b/golem-client/tests/fixtures/stream-session-v1/malformed.json @@ -19,10 +19,12 @@ {"expectedCode":"malformed-message","input":"{\"attemptId\":\"not-a-uuid\",\"operation\":\"resume\",\"outputCursors\":[],\"sessionToken\":\"token\",\"type\":\"resumeAttach\",\"version\":1}","lane":"text","name":"invalid-attempt-uuid"}, {"expectedCode":"validation-error","input":"\"9007199254740993\"","lane":"schema-value","name":"u64-above-javascript-safe-boundary-as-string-control","schema":{"kind":"u32"}}, {"expectedCode":"validation-error","input":"9007199254740993","lane":"schema-value","name":"u64-json-number","schema":{"kind":"u64"}}, - {"expectedCode":"malformed-message","input":"{\"bytes\":\"-_8=\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-url-safe-alphabet","schema":{"kind":"binary"}}, - {"expectedCode":"malformed-message","input":"{\"bytes\":\"+/8\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-missing-padding","schema":{"kind":"binary"}}, - {"expectedCode":"malformed-message","input":"{\"bytes\":\"+/9=\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-nonzero-unused-bits","schema":{"kind":"binary"}}, - {"expectedCode":"validation-error","input":"{\"bytes\":\"AA==\",\"mimeType\":\"invalid\"}","lane":"schema-value","name":"binary-invalid-mime","schema":{"kind":"binary"}}, + {"expectedCode":"malformed-message","input":"{\"bytes\":\"+/8=\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-standard-alphabet","schema":{"kind":"binary"}}, + {"expectedCode":"malformed-message","input":"{\"bytes\":\"-_8=\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-padding","schema":{"kind":"binary"}}, + {"expectedCode":"malformed-message","input":"{\"bytes\":\"-_9\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-nonzero-trailing-bits","schema":{"kind":"binary"}}, + {"expectedCode":"malformed-message","input":"{\"bytes\":\"A\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-impossible-length","schema":{"kind":"binary"}}, + {"expectedCode":"malformed-message","input":"{\"bytes\":\"__9\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-nonzero-unused-bits","schema":{"kind":"binary"}}, + {"expectedCode":"validation-error","input":"{\"bytes\":\"AA\",\"mimeType\":\"invalid\"}","lane":"schema-value","name":"binary-invalid-mime","schema":{"kind":"binary"}}, {"expectedCode":"malformed-message","input":"{\"$float\":\"infinity\"}","lane":"schema-value","name":"unknown-exceptional-float","schema":{"kind":"f64"}}, {"expectedCode":"validation-error","input":"{\"expected\":true,\"unknown\":false}","lane":"schema-value","name":"unknown-record-field","schema":{"fields":[{"name":"expected","type":{"kind":"bool"}}],"kind":"record"}}, {"expectedCode":"validation-error","input":"{\"$option\":\"none\",\"value\":1}","lane":"schema-value","name":"illegal-none-value","schema":{"inner":{"kind":"u8"},"kind":"option"}}, diff --git a/golem-client/tests/fixtures/stream-session-v1/schema-values.json b/golem-client/tests/fixtures/stream-session-v1/schema-values.json index d60b402900..538c2a356b 100644 --- a/golem-client/tests/fixtures/stream-session-v1/schema-values.json +++ b/golem-client/tests/fixtures/stream-session-v1/schema-values.json @@ -15,7 +15,7 @@ {"canonical":"\"λ\"","name":"char","schema":{"kind":"char"}}, {"canonical":"\"Golem\"","name":"string","schema":{"kind":"string"}}, {"canonical":"{\"language\":\"en-GB\",\"text\":\"hello\"}","name":"text","schema":{"kind":"text"}}, - {"canonical":"{\"bytes\":\"+/8=\",\"mimeType\":\"application/octet-stream\"}","name":"binary-padded-standard-base64","schema":{"kind":"binary","maxBytes":16,"mimeTypes":["application/octet-stream"]}}, + {"canonical":"{\"bytes\":\"-_8\",\"mimeType\":\"application/octet-stream\"}","name":"binary-unpadded-base64url","schema":{"kind":"binary","maxBytes":16,"mimeTypes":["application/octet-stream"]}}, {"canonical":"\"/tmp/input.wav\"","name":"path","schema":{"kind":"path"}}, {"canonical":"\"https://example.com/a\"","name":"url","schema":{"kind":"url"}}, {"canonical":"\"2026-08-28T12:34:56.123456789Z\"","name":"datetime","schema":{"kind":"datetime"}}, diff --git a/golem-common/src/schema/public_json.rs b/golem-common/src/schema/public_json.rs index 03259dee96..273e0076ab 100644 --- a/golem-common/src/schema/public_json.rs +++ b/golem-common/src/schema/public_json.rs @@ -22,7 +22,7 @@ use crate::schema::{ SchemaValue, UnionValuePayload, VariantValuePayload, }; use base64::Engine; -use base64::engine::general_purpose::STANDARD; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use golem_schema::schema::render::{from_json_value, to_json_value}; use serde_json::{Map, Number, Value}; use std::collections::HashSet; @@ -446,12 +446,12 @@ where )); } let encoded = required_string(object, "bytes")?; - let bytes = STANDARD.decode(encoded).map_err(|_| { - PublicSchemaValueError::malformed("binary bytes are not canonical padded base64") + let bytes = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| { + PublicSchemaValueError::malformed("binary bytes are not canonical unpadded base64url") })?; - if STANDARD.encode(&bytes) != encoded { + if URL_SAFE_NO_PAD.encode(&bytes) != encoded { return Err(PublicSchemaValueError::malformed( - "binary bytes are not canonical padded base64", + "binary bytes are not canonical unpadded base64url", )); } let mime_type = object @@ -605,7 +605,7 @@ where let mut object = Map::new(); object.insert( "bytes".to_string(), - Value::String(STANDARD.encode(&value.bytes)), + Value::String(URL_SAFE_NO_PAD.encode(&value.bytes)), ); if let Some(mime_type) = &value.mime_type { object.insert("mimeType".to_string(), Value::String(mime_type.clone())); @@ -1357,7 +1357,7 @@ mod tests { } #[test] - fn safe_integers_and_standard_base64_round_trip() { + fn safe_integers_and_base64url_round_trip() { assert_eq!( decode( &SchemaType::u64(), @@ -1378,7 +1378,7 @@ mod tests { assert_eq!( decode( &SchemaType::binary(BinaryRestrictions::default()), - json!({"bytes":"+/8=","mimeType":"application/octet-stream"}), + json!({"bytes":"-_8","mimeType":"application/octet-stream"}), PublicStreamReferencePolicy::None, ) .unwrap(), @@ -1539,6 +1539,11 @@ mod tests { json!(9007199254740993u64), PublicErrorCode::ValidationError, ), + ( + SchemaType::binary(BinaryRestrictions::default()), + json!({"bytes":"+/8=","mimeType":"application/octet-stream"}), + PublicErrorCode::MalformedMessage, + ), ( SchemaType::binary(BinaryRestrictions::default()), json!({"bytes":"-_8=","mimeType":"application/octet-stream"}), @@ -1546,7 +1551,7 @@ mod tests { ), ( SchemaType::binary(BinaryRestrictions::default()), - json!({"bytes":"+/8","mimeType":"application/octet-stream"}), + json!({"bytes":"-_9","mimeType":"application/octet-stream"}), PublicErrorCode::MalformedMessage, ), ( diff --git a/sdks/ts/packages/golem-ts-bridge/src/index.ts b/sdks/ts/packages/golem-ts-bridge/src/index.ts index 7a9204c1a0..a7bbc48e0d 100644 --- a/sdks/ts/packages/golem-ts-bridge/src/index.ts +++ b/sdks/ts/packages/golem-ts-bridge/src/index.ts @@ -250,8 +250,9 @@ export type StreamingRemoteMethod = { // // These mirror the Rust `SchemaValue` / `TypedSchemaValue` serde shapes // (`#[serde(tag = "kind", content = "value", rename_all = "kebab-case")]`). -// Request `parameters` / `methodParameters` and agent `config` values travel -// as a bare `SchemaValue`; invocation results come back as a `TypedSchemaValue`. +// Request `parameters` / `methodParameters` travel as bare `SchemaValue`s; +// config values use their declared public schema codec, and invocation results +// come back as a `TypedSchemaValue`. // =========================================================================== export interface TextValuePayload { @@ -353,7 +354,7 @@ export interface InvocationReceipt { export interface AgentConfigEntry { path: string[]; - value: SchemaValue; + value: PublicValue; } export interface CreateAgentRequest { @@ -1720,14 +1721,11 @@ class PublicValueValidator { const input = publicObject(value, 'binary'); publicExactOptionalMembers(input, new Set(['bytes']), new Set(['mimeType']), 'binary'); const encoded = publicString(input.bytes, 'binary bytes'); - if ( - encoded.length % 4 !== 0 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded) - ) - this.fail('malformed-message', 'binary bytes are not canonical padded base64'); - const bytes = Buffer.from(encoded, 'base64'); - if (bytes.toString('base64') !== encoded) - this.fail('malformed-message', 'binary bytes are not canonical padded base64'); + if (!/^[A-Za-z0-9_-]*$/u.test(encoded) || encoded.length % 4 === 1) + this.fail('malformed-message', 'binary bytes are not canonical unpadded base64url'); + const bytes = Buffer.from(encoded, 'base64url'); + if (bytes.toString('base64url') !== encoded) + this.fail('malformed-message', 'binary bytes are not canonical unpadded base64url'); const mime = input.mimeType === undefined ? undefined : publicString(input.mimeType, 'MIME type'); if (mime !== undefined && !/^[A-Za-z0-9!#$&^_.+\-]+\/[A-Za-z0-9!#$&^_.+\-]+$/u.test(mime)) @@ -3003,7 +3001,7 @@ class StreamingSession { throw new StreamingProtocolError('malformed-message', 'invalid binary item metadata'); const deliveredSequence = output.stream.accept(sequence, 1n, 'binary'); output.stream.push( - { bytes: payload.toString('base64'), mimeType: metadata.mimeType }, + { bytes: payload.toString('base64url'), mimeType: metadata.mimeType }, { token: metadata.cursorToken, sequence }, payload.length + (metadata.mimeType ? Buffer.byteLength(metadata.mimeType) : 0), deliveredSequence, @@ -3259,19 +3257,16 @@ class StreamingSession { throw new StreamingProtocolError('schema-mismatch', 'invalid binary stream item'); let payload: Buffer; if (typeof encoded.bytes === 'string') { - if ( - encoded.bytes.length % 4 !== 0 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded.bytes) - ) + if (!/^[A-Za-z0-9_-]*$/u.test(encoded.bytes) || encoded.bytes.length % 4 === 1) throw new StreamingProtocolError( 'schema-mismatch', - 'binary bytes are not canonical base64', + 'binary bytes are not canonical unpadded base64url', ); - payload = Buffer.from(encoded.bytes, 'base64'); - if (payload.toString('base64') !== encoded.bytes) + payload = Buffer.from(encoded.bytes, 'base64url'); + if (payload.toString('base64url') !== encoded.bytes) throw new StreamingProtocolError( 'schema-mismatch', - 'binary bytes are not canonical base64', + 'binary bytes are not canonical unpadded base64url', ); } else if (encoded.bytes instanceof Uint8Array) payload = Buffer.from(encoded.bytes); else throw new StreamingProtocolError('schema-mismatch', 'invalid binary stream bytes'); diff --git a/sdks/ts/packages/golem-ts-bridge/test/runtime.test.mjs b/sdks/ts/packages/golem-ts-bridge/test/runtime.test.mjs index 975bfca101..a8ced0482a 100644 --- a/sdks/ts/packages/golem-ts-bridge/test/runtime.test.mjs +++ b/sdks/ts/packages/golem-ts-bridge/test/runtime.test.mjs @@ -110,10 +110,14 @@ test('REST request JSON preserves exact bigint values', async () => { envName: 'env', agentTypeName: 'agent', parameters: { kind: 'u64', value: 18_446_744_073_709_551_615n }, + config: [{ path: ['limits', 'maximum'], value: '18446744073709551615' }], }, ); assert.match(body, /"value":18446744073709551615(?:[,}])/u); - assert.doesNotMatch(body, /"18446744073709551615"/u); + assert.match( + body, + /"config":\[\{"path":\["limits","maximum"\],"value":"18446744073709551615"\}\]/u, + ); } finally { await new Promise((resolve) => http.close(resolve)); } @@ -951,14 +955,18 @@ test('public value codec enforces canonical values, restrictions, and strict sha }, }); assert.deepEqual( - binary.validate({ bytes: '+/8=', mimeType: 'application/octet-stream' }, 'none'), - { bytes: '+/8=', mimeType: 'application/octet-stream' }, + binary.validate({ bytes: '-_8', mimeType: 'application/octet-stream' }, 'none'), + { bytes: '-_8', mimeType: 'application/octet-stream' }, + ); + assert.throws( + () => binary.validate({ bytes: '+/8=', mimeType: 'application/octet-stream' }, 'none'), + { code: 'malformed-message' }, ); assert.throws( - () => binary.validate({ bytes: '-_8=', mimeType: 'application/octet-stream' }, 'none'), + () => binary.validate({ bytes: '-_9', mimeType: 'application/octet-stream' }, 'none'), { code: 'malformed-message' }, ); - assert.throws(() => binary.validate({ bytes: '+/8=', mimeType: 'image/png' }, 'none'), { + assert.throws(() => binary.validate({ bytes: '-_8', mimeType: 'image/png' }, 'none'), { code: 'validation-error', }); From 3b7c7f73ca7f93edd168e71a7d14212a186dde3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 24 Sep 2026 11:54:02 +0200 Subject: [PATCH 25/37] fix: enforce reflection conformance edge cases --- golem-schema/src/schema/render/json_schema.rs | 54 ++++++++++++++----- golem-schema/src/schema/render/tests.rs | 8 +++ .../src/internal/reflection/schemaRender.ts | 4 +- .../test/reflection-conformance.test.ts | 9 ++++ .../reflection_conformance_corpus_wbtest.mbt | 2 +- .../reflection_conformance_wbtest.mbt | 32 +++++++++-- .../golem_sdk/reflection/schema_ref.mbt | 18 ++++++- .../reflection/schema_ref_wbtest.mbt | 17 ++++++ .../rust/golem-rust/src/agentic/reflection.rs | 8 +++ .../scala/golem/reflection/SchemaRef.scala | 41 +++++++++++--- .../ReflectionConformanceCorpus.scala | 41 ++++++++++++++ .../ReflectionConformanceSpec.scala | 28 ++++++++-- .../golem-ts-sdk/src/schema/render.ts | 35 ++++++++++-- .../tests/reflection-conformance.test.ts | 11 +++- test-data/reflection-conformance/v1.json | 27 +++++++++- 15 files changed, 299 insertions(+), 36 deletions(-) diff --git a/golem-schema/src/schema/render/json_schema.rs b/golem-schema/src/schema/render/json_schema.rs index 097491a7bc..c1c21e15a7 100644 --- a/golem-schema/src/schema/render/json_schema.rs +++ b/golem-schema/src/schema/render/json_schema.rs @@ -18,9 +18,9 @@ use crate::schema::graph::SchemaGraph; use crate::schema::metadata::{MetadataEnvelope, TypeId}; use crate::schema::schema_type::{ - BinaryRestrictions, DiscriminatorRule, PathSpec, PermissionCardSpec, QuantitySpec, - QuantityValue, QuotaTokenSpec, ResultSpec, SchemaType, SecretSpec, TextRestrictions, - UnionBranch, UnionSpec, UrlRestrictions, VariantCaseType, + BinaryRestrictions, DiscriminatorRule, NumericBound, NumericRestrictions, PathSpec, + PermissionCardSpec, QuantitySpec, QuantityValue, QuotaTokenSpec, ResultSpec, SchemaType, + SecretSpec, TextRestrictions, UnionBranch, UnionSpec, UrlRestrictions, VariantCaseType, }; use serde_json::{Map, Number, Value}; use std::collections::{HashMap, HashSet}; @@ -669,11 +669,11 @@ pub(super) fn render_type( SchemaType::S8 { .. } => integer_schema(i8::MIN as i64, i8::MAX as i64), SchemaType::S16 { .. } => integer_schema(i16::MIN as i64, i16::MAX as i64), SchemaType::S32 { .. } => integer_schema(i32::MIN as i64, i32::MAX as i64), - SchemaType::S64 { .. } => signed_64_schema(), + SchemaType::S64 { restrictions, .. } => signed_64_schema(restrictions.as_ref()), SchemaType::U8 { .. } => integer_schema(0, u8::MAX as i64), SchemaType::U16 { .. } => integer_schema(0, u16::MAX as i64), SchemaType::U32 { .. } => integer_schema(0, u32::MAX as i64), - SchemaType::U64 { .. } => unsigned_64_schema(), + SchemaType::U64 { restrictions, .. } => unsigned_64_schema(restrictions.as_ref()), SchemaType::F32 { .. } | SchemaType::F64 { .. } => { obj([("type", Value::String("number".to_string()))]) } @@ -815,7 +815,7 @@ pub(super) fn render_type( ]), SchemaType::Duration { .. } => obj([ ("type", Value::String("object".to_string())), - ("properties", obj([("nanoseconds", signed_64_schema())])), + ("properties", obj([("nanoseconds", signed_64_schema(None))])), ( "required", Value::Array(vec![Value::String("nanoseconds".to_string())]), @@ -915,17 +915,45 @@ fn integer_schema(min: i64, max: i64) -> Value { ]) } -fn unsigned_64_schema() -> Value { +fn unsigned_64_schema(restrictions: Option<&NumericRestrictions>) -> Value { + let minimum = restrictions + .and_then(|value| value.min) + .and_then(|bound| match bound { + NumericBound::Unsigned(value) => Some(value), + _ => None, + }) + .unwrap_or(0); + let maximum = restrictions + .and_then(|value| value.max) + .and_then(|bound| match bound { + NumericBound::Unsigned(value) => Some(value), + _ => None, + }) + .unwrap_or(u64::MAX); obj([ ("type", Value::String("string".to_string())), ("format", Value::String("uint64".to_string())), ("pattern", Value::String("^(?:0|[1-9][0-9]*)$".to_string())), - ("x-golem-minimum", Value::String("0".to_string())), - ("x-golem-maximum", Value::String(u64::MAX.to_string())), + ("x-golem-minimum", Value::String(minimum.to_string())), + ("x-golem-maximum", Value::String(maximum.to_string())), ]) } -fn signed_64_schema() -> Value { +fn signed_64_schema(restrictions: Option<&NumericRestrictions>) -> Value { + let minimum = restrictions + .and_then(|value| value.min) + .and_then(|bound| match bound { + NumericBound::Signed(value) => Some(value), + _ => None, + }) + .unwrap_or(i64::MIN); + let maximum = restrictions + .and_then(|value| value.max) + .and_then(|bound| match bound { + NumericBound::Signed(value) => Some(value), + _ => None, + }) + .unwrap_or(i64::MAX); obj([ ("type", Value::String("string".to_string())), ("format", Value::String("int64".to_string())), @@ -933,8 +961,8 @@ fn signed_64_schema() -> Value { "pattern", Value::String("^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$".to_string()), ), - ("x-golem-minimum", Value::String(i64::MIN.to_string())), - ("x-golem-maximum", Value::String(i64::MAX.to_string())), + ("x-golem-minimum", Value::String(minimum.to_string())), + ("x-golem-maximum", Value::String(maximum.to_string())), ]) } @@ -1163,7 +1191,7 @@ fn url_schema(restrictions: &UrlRestrictions) -> Map { fn quantity_schema(spec: &QuantitySpec) -> Map { let mut props = Map::new(); - props.insert("mantissa".to_string(), signed_64_schema()); + props.insert("mantissa".to_string(), signed_64_schema(None)); props.insert( "scale".to_string(), obj([("type", Value::String("integer".to_string()))]), diff --git a/golem-schema/src/schema/render/tests.rs b/golem-schema/src/schema/render/tests.rs index aa169d7edc..90aab7898f 100644 --- a/golem-schema/src/schema/render/tests.rs +++ b/golem-schema/src/schema/render/tests.rs @@ -367,6 +367,14 @@ fn malformed_json_and_schema_values_are_typed_errors() { fn conformance_fixture(name: &str) -> SchemaGraph { let root = match name { "s64" => SchemaType::s64(), + "constrained-s64" => SchemaType::S64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::Signed(-9_007_199_254_740_993)), + max: Some(NumericBound::Signed(9_007_199_254_740_993)), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, "u64" => SchemaType::u64(), "binary" => SchemaType::binary(BinaryRestrictions::default()), "duration" => SchemaType::duration(), diff --git a/sdks/effect/src/internal/reflection/schemaRender.ts b/sdks/effect/src/internal/reflection/schemaRender.ts index 71a6361ca8..083e30524d 100644 --- a/sdks/effect/src/internal/reflection/schemaRender.ts +++ b/sdks/effect/src/internal/reflection/schemaRender.ts @@ -1067,7 +1067,9 @@ function base64UrlToBytes(value: string, path: Path): Uint8Array { if (value[index + 2] !== undefined) bytes.push(((b & 15) << 4) | (c >> 2)) if (value[index + 3] !== undefined) bytes.push(((c & 3) << 6) | d) } - return Uint8Array.from(bytes) + const result = Uint8Array.from(bytes) + if (bytesToBase64(result) !== value) fail(path, "invalid base64url without padding") + return result } function rejectUnknownFields( diff --git a/sdks/effect/test/reflection-conformance.test.ts b/sdks/effect/test/reflection-conformance.test.ts index 741d7fcc08..c5994945d8 100644 --- a/sdks/effect/test/reflection-conformance.test.ts +++ b/sdks/effect/test/reflection-conformance.test.ts @@ -102,6 +102,13 @@ function fixture(name: string): SchemaRef { switch (name) { case "s64": return ref(t.s64()) + case "constrained-s64": + return ref( + t.s64({ + min: { tag: "signed", val: -9_007_199_254_740_993n }, + max: { tag: "signed", val: 9_007_199_254_740_993n }, + }), + ) case "u64": return ref(t.u64()) case "binary": @@ -162,8 +169,10 @@ function atPointer(value: JsonValue, pointer: string): JsonValue { function expectSubset(actual: JsonValue, expected: JsonValue): void { if (expected !== null && typeof expected === "object" && !Array.isArray(expected)) { expect(actual).not.toBeNull() + expect(typeof actual).toBe("object") expect(Array.isArray(actual)).toBe(false) for (const [key, value] of Object.entries(expected)) { + expect(Object.prototype.hasOwnProperty.call(actual, key)).toBe(true) expectSubset((actual as Record)[key], value) } } else { diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt index fc7eeba8a9..31633d3a20 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt @@ -9,4 +9,4 @@ // Generated by scripts/sync-reflection-conformance.mjs. Do not edit. ///| -let reflection_conformance_corpus_json : String = "{\n \"version\": \"1.0.0\",\n \"schemaKinds\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ],\n \"restrictionKinds\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ],\n \"caseIds\": [\n \"canonical/s64-min\",\n \"canonical/s64-max\",\n \"canonical/u64-max\",\n \"canonical/duration-min\",\n \"canonical/duration-max\",\n \"canonical/quantity-wide-mantissa\",\n \"canonical/omitted-options\",\n \"canonical/binary-mime\",\n \"errors/s64-plus\",\n \"errors/s64-leading-zero\",\n \"errors/s64-negative-zero\",\n \"errors/s64-overflow\",\n \"errors/u64-negative\",\n \"errors/u64-overflow\",\n \"errors/wide-wrong-json-type\",\n \"errors/duration-legacy-string\",\n \"errors/quantity-negative-zero\",\n \"errors/binary-noncanonical-base64\",\n \"json-schema/s64\",\n \"json-schema/u64\",\n \"json-schema/duration-nanoseconds\",\n \"json-schema/quantity-mantissa\",\n \"json-schema/optional-required\",\n \"json-schema/unsupported-leaves\",\n \"schema/all-kinds\",\n \"schema/all-restrictions\",\n \"graph/references-validation-equivalence\",\n \"tool/canonical-input\",\n \"config/canonical-entry\",\n \"constraints/rejected-value\",\n \"compatibility/result\",\n \"compatibility/custom-error\"\n ],\n \"cases\": [\n {\n \"id\": \"canonical/s64-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"-9223372036854775808\",\n \"expected\": \"-9223372036854775808\"\n },\n {\n \"id\": \"canonical/s64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775807\",\n \"expected\": \"9223372036854775807\"\n },\n {\n \"id\": \"canonical/u64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551615\",\n \"expected\": \"18446744073709551615\"\n },\n {\n \"id\": \"canonical/duration-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"-9223372036854775808\"\n },\n \"expected\": {\n \"nanoseconds\": \"-9223372036854775808\"\n }\n },\n {\n \"id\": \"canonical/duration-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"9223372036854775807\"\n },\n \"expected\": {\n \"nanoseconds\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"canonical/quantity-wide-mantissa\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n }\n },\n {\n \"id\": \"canonical/omitted-options\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"optional-record\",\n \"input\": {},\n \"expected\": {\n \"direct\": null,\n \"referenced\": null\n }\n },\n {\n \"id\": \"canonical/binary-mime\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"binary\",\n \"input\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n },\n \"expected\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n }\n },\n {\n \"id\": \"errors/s64-plus\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"+1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-leading-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"01\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"-0\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775808\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-negative\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"-1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551616\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/wide-wrong-json-type\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": 1,\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/duration-legacy-string\",\n \"operation\": \"reject\",\n \"fixture\": \"duration\",\n \"input\": \"PT1S\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-0\",\n \"scale\": 0,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/binary-noncanonical-base64\",\n \"operation\": \"reject\",\n \"fixture\": \"binary\",\n \"inputs\": [\n {\n \"bytes\": \"+/8\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8=\",\n \"mimeType\": \"application/octet-stream\"\n }\n ],\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"json-schema/s64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/u64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"u64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"uint64\",\n \"pattern\": \"^(?:0|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"0\",\n \"x-golem-maximum\": \"18446744073709551615\"\n }\n },\n {\n \"id\": \"json-schema/duration-nanoseconds\",\n \"operation\": \"json-schema\",\n \"fixture\": \"duration\",\n \"path\": \"/properties/nanoseconds\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/quantity-mantissa\",\n \"operation\": \"json-schema\",\n \"fixture\": \"quantity\",\n \"path\": \"/properties/mantissa\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/optional-required\",\n \"operation\": \"json-schema\",\n \"fixture\": \"optional-record\",\n \"path\": \"/required\",\n \"expected\": []\n },\n {\n \"id\": \"json-schema/unsupported-leaves\",\n \"operation\": \"semantic\",\n \"fixture\": \"unsupported-leaves\",\n \"expected\": {\n \"count\": 5,\n \"schema\": {\n \"not\": {}\n }\n }\n },\n {\n \"id\": \"schema/all-kinds\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-kinds\",\n \"expected\": {\n \"names\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ]\n }\n },\n {\n \"id\": \"schema/all-restrictions\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-restrictions\",\n \"expected\": {\n \"names\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ]\n }\n },\n {\n \"id\": \"graph/references-validation-equivalence\",\n \"operation\": \"semantic\",\n \"fixture\": \"graph\",\n \"expected\": {\n \"valid\": true,\n \"equivalent\": true\n }\n },\n {\n \"id\": \"tool/canonical-input\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"tool-input\",\n \"input\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ]\n },\n \"expected\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ],\n \"ignoreCase\": null\n }\n },\n {\n \"id\": \"config/canonical-entry\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"config-entry\",\n \"input\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n },\n \"expected\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"constraints/rejected-value\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-u32\",\n \"input\": 11,\n \"expected\": {\n \"kind\": \"constraint-violation\"\n }\n },\n {\n \"id\": \"compatibility/result\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"result\",\n \"input\": {\n \"ok\": \"ready\"\n },\n \"expected\": {\n \"ok\": \"ready\"\n }\n },\n {\n \"id\": \"compatibility/custom-error\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"custom-error\",\n \"input\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n },\n \"expected\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n }\n }\n ]\n}\n" +let reflection_conformance_corpus_json : String = "{\n \"version\": \"1.0.0\",\n \"schemaKinds\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ],\n \"restrictionKinds\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ],\n \"caseIds\": [\n \"canonical/s64-min\",\n \"canonical/s64-max\",\n \"canonical/u64-max\",\n \"canonical/duration-min\",\n \"canonical/duration-max\",\n \"canonical/quantity-wide-mantissa\",\n \"canonical/omitted-options\",\n \"canonical/binary-mime\",\n \"errors/s64-plus\",\n \"errors/s64-leading-zero\",\n \"errors/s64-negative-zero\",\n \"errors/s64-overflow\",\n \"errors/u64-negative\",\n \"errors/u64-overflow\",\n \"errors/wide-wrong-json-type\",\n \"errors/duration-legacy-string\",\n \"errors/quantity-negative-zero\",\n \"errors/quantity-extra-field\",\n \"errors/binary-noncanonical-base64\",\n \"json-schema/s64\",\n \"json-schema/s64-restrictions\",\n \"json-schema/u64\",\n \"json-schema/duration-nanoseconds\",\n \"json-schema/quantity-mantissa\",\n \"json-schema/optional-required\",\n \"json-schema/unsupported-leaves\",\n \"schema/all-kinds\",\n \"schema/all-restrictions\",\n \"graph/references-validation-equivalence\",\n \"tool/canonical-input\",\n \"config/canonical-entry\",\n \"constraints/rejected-value\",\n \"compatibility/result\",\n \"compatibility/custom-error\"\n ],\n \"cases\": [\n {\n \"id\": \"canonical/s64-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"-9223372036854775808\",\n \"expected\": \"-9223372036854775808\"\n },\n {\n \"id\": \"canonical/s64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775807\",\n \"expected\": \"9223372036854775807\"\n },\n {\n \"id\": \"canonical/u64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551615\",\n \"expected\": \"18446744073709551615\"\n },\n {\n \"id\": \"canonical/duration-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"-9223372036854775808\"\n },\n \"expected\": {\n \"nanoseconds\": \"-9223372036854775808\"\n }\n },\n {\n \"id\": \"canonical/duration-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"9223372036854775807\"\n },\n \"expected\": {\n \"nanoseconds\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"canonical/quantity-wide-mantissa\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n }\n },\n {\n \"id\": \"canonical/omitted-options\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"optional-record\",\n \"input\": {},\n \"expected\": {\n \"direct\": null,\n \"referenced\": null\n }\n },\n {\n \"id\": \"canonical/binary-mime\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"binary\",\n \"input\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n },\n \"expected\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n }\n },\n {\n \"id\": \"errors/s64-plus\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"+1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-leading-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"01\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"-0\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775808\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-negative\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"-1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551616\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/wide-wrong-json-type\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": 1,\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/duration-legacy-string\",\n \"operation\": \"reject\",\n \"fixture\": \"duration\",\n \"input\": \"PT1S\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-0\",\n \"scale\": 0,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-extra-field\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"1\",\n \"scale\": 0,\n \"unit\": \"m\",\n \"extra\": true\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/binary-noncanonical-base64\",\n \"operation\": \"reject\",\n \"fixture\": \"binary\",\n \"inputs\": [\n {\n \"bytes\": \"+/8\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8=\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_9\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"A\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8\",\n \"mime_type\": \"application/octet-stream\"\n }\n ],\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"json-schema/s64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/s64-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9007199254740993\",\n \"x-golem-maximum\": \"9007199254740993\"\n }\n },\n {\n \"id\": \"json-schema/u64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"u64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"uint64\",\n \"pattern\": \"^(?:0|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"0\",\n \"x-golem-maximum\": \"18446744073709551615\"\n }\n },\n {\n \"id\": \"json-schema/duration-nanoseconds\",\n \"operation\": \"json-schema\",\n \"fixture\": \"duration\",\n \"path\": \"/properties/nanoseconds\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/quantity-mantissa\",\n \"operation\": \"json-schema\",\n \"fixture\": \"quantity\",\n \"path\": \"/properties/mantissa\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/optional-required\",\n \"operation\": \"json-schema\",\n \"fixture\": \"optional-record\",\n \"path\": \"/required\",\n \"expected\": []\n },\n {\n \"id\": \"json-schema/unsupported-leaves\",\n \"operation\": \"semantic\",\n \"fixture\": \"unsupported-leaves\",\n \"expected\": {\n \"count\": 5,\n \"schema\": {\n \"not\": {}\n }\n }\n },\n {\n \"id\": \"schema/all-kinds\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-kinds\",\n \"expected\": {\n \"names\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ]\n }\n },\n {\n \"id\": \"schema/all-restrictions\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-restrictions\",\n \"expected\": {\n \"names\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ]\n }\n },\n {\n \"id\": \"graph/references-validation-equivalence\",\n \"operation\": \"semantic\",\n \"fixture\": \"graph\",\n \"expected\": {\n \"valid\": true,\n \"equivalent\": true\n }\n },\n {\n \"id\": \"tool/canonical-input\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"tool-input\",\n \"input\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ]\n },\n \"expected\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ],\n \"ignoreCase\": null\n }\n },\n {\n \"id\": \"config/canonical-entry\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"config-entry\",\n \"input\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n },\n \"expected\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"constraints/rejected-value\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-u32\",\n \"input\": 11,\n \"expected\": {\n \"kind\": \"constraint-violation\"\n }\n },\n {\n \"id\": \"compatibility/result\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"result\",\n \"input\": {\n \"ok\": \"ready\"\n },\n \"expected\": {\n \"ok\": \"ready\"\n }\n },\n {\n \"id\": \"compatibility/custom-error\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"custom-error\",\n \"input\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n },\n \"expected\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n }\n }\n ]\n}\n" diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt index 686422ecb4..2c0c36f074 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt @@ -30,6 +30,15 @@ fn corpus_count(value : Json) -> Int raise { } } +///| +fn corpus_bool(value : Json) -> Bool raise { + match value { + True => true + False => false + _ => fail("expected conformance JSON boolean") + } +} + ///| fn corpus_field( name : String, @@ -42,6 +51,16 @@ fn corpus_field( fn conformance_fixture(name : String) -> SchemaRef raise { let root = match name { "s64" => @model.schema_type(S64(None)) + "constrained-s64" => + @model.schema_type( + S64( + Some({ + min: Some(@types.Signed(-9007199254740993L)), + max: Some(@types.Signed(9007199254740993L)), + unit: None, + }), + ), + ) "u64" => @model.schema_type(U64(None)) "binary" => @model.schema_type( @@ -227,6 +246,8 @@ fn assert_conformance_semantic( ) } "graph" => { + let expected_fields = corpus_object(expected) + let referenced = conformance_fixture("optional-record") let inline = test_schema( Record([ corpus_field( @@ -239,9 +260,13 @@ fn assert_conformance_semantic( ), ]), ) + let valid = @model.validate_graph(referenced.graph()).length() == 0 + let equivalent = referenced.pack_json(Json::object({})) == + inline.pack_json(Json::object({})) + assert_eq(valid, corpus_bool(expected_fields.get("valid").unwrap())) assert_eq( - conformance_fixture("optional-record").pack_json(Json::object({})), - inline.pack_json(Json::object({})), + equivalent, + corpus_bool(expected_fields.get("equivalent").unwrap()), ) } other => fail("unknown semantic conformance fixture " + other) @@ -272,12 +297,13 @@ test "reflection conformance corpus executes the complete case-ID set" { ) } "reject" => { + let schema = conformance_fixture(name) let inputs = match test_case.get("inputs") { Some(values) => corpus_array(values) None => [test_case.get("input").unwrap()] } for input in inputs { - try conformance_fixture(name).pack_json(input) catch { + try schema.pack_json(input) catch { _ => () } noraise { _ => fail("conformance case was accepted: " + id) diff --git a/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt b/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt index 9f69435047..a9398694bd 100644 --- a/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt +++ b/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt @@ -556,13 +556,27 @@ fn SchemaRef::type_contains_stream( fn SchemaRef::resolve( self : SchemaRef, schema : @model.SchemaType, +) -> @model.SchemaType raise ReflectError { + self.resolve_with(schema, {}) +} + +///| +fn SchemaRef::resolve_with( + self : SchemaRef, + schema : @model.SchemaType, + visited : Map[String, Unit], ) -> @model.SchemaType raise ReflectError { match schema.body { - Ref(id) => + Ref(id) => { + guard !visited.contains(id) else { + raise Schema("cyclic schema reference '" + id + "'") + } + visited[id] = () match self.graph.defs.iter().find_first(def => def.id == id) { - Some(def) => self.resolve(def.body) + Some(def) => self.resolve_with(def.body, visited) None => raise Schema("dangling schema reference '" + id + "'") } + } _ => schema } } diff --git a/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt index 97b55108c6..5cc03220ea 100644 --- a/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt @@ -50,6 +50,23 @@ test "JSON Schema projects records and recursive definitions" { ) } +///| +test "canonical JSON rejects cyclic alias references" { + let schema = SchemaRef::new({ + defs: [ + { id: "alias-a", name: None, body: @model.schema_type(Ref("alias-b")), }, + { id: "alias-b", name: None, body: @model.schema_type(Ref("alias-a")), }, + ], + root: @model.schema_type(Ref("alias-a")), + }) + try schema.pack_json(Json::null()) catch { + Schema(message) => assert_eq(message, "cyclic schema reference 'alias-a'") + error => fail("unexpected error: \{repr(error)}") + } noraise { + _ => fail("expected cyclic alias rejection") + } +} + ///| test "JSON Schema retains text and binary constraints" { let text = test_schema( diff --git a/sdks/rust/golem-rust/src/agentic/reflection.rs b/sdks/rust/golem-rust/src/agentic/reflection.rs index 830d1b5278..d1ec6fd697 100644 --- a/sdks/rust/golem-rust/src/agentic/reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/reflection.rs @@ -1761,6 +1761,14 @@ mod tests { fn conformance_schema(name: &str) -> SchemaRef { let root = match name { "s64" => SchemaType::s64(), + "constrained-s64" => SchemaType::S64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::Signed(-9_007_199_254_740_993)), + max: Some(NumericBound::Signed(9_007_199_254_740_993)), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, "u64" => SchemaType::u64(), "binary" => SchemaType::binary(BinaryRestrictions::default()), "duration" => SchemaType::duration(), diff --git a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala index 11667a6d9e..d6d95bb3ec 100644 --- a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala @@ -99,16 +99,35 @@ private object CanonicalJson { } typed("number", limits: _*) } - def wideInteger(unsigned: Boolean): Json = + def restrictedWideBound( + bound: Option[NumericBound], + fallback: BigInt, + minimum: Boolean + ): BigInt = { + val value = bound.flatMap { + case NumericBound.Signed(value) => Some(BigInt(value)) + case NumericBound.Unsigned(value) => Some(BigInt(java.lang.Long.toUnsignedString(value))) + case NumericBound.FloatBits(_) => None + }.getOrElse(fallback) + if (minimum) value.max(fallback) else value.min(fallback) + } + def wideInteger(unsigned: Boolean, restrictions: Option[NumericRestrictions]): Json = { + val baseMinimum = if (unsigned) BigInt(0) else BigInt(Long.MinValue) + val baseMaximum = if (unsigned) U64Max else BigInt(Long.MaxValue) typed( "string", "format" -> Json.String(if (unsigned) "uint64" else "int64"), "pattern" -> Json.String( if (unsigned) "^(?:0|[1-9][0-9]*)$" else "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$" ), - "x-golem-minimum" -> Json.String(if (unsigned) "0" else Long.MinValue.toString), - "x-golem-maximum" -> Json.String(if (unsigned) U64Max.toString else Long.MaxValue.toString) + "x-golem-minimum" -> Json.String( + restrictedWideBound(restrictions.flatMap(_.min), baseMinimum, minimum = true).toString + ), + "x-golem-maximum" -> Json.String( + restrictedWideBound(restrictions.flatMap(_.max), baseMaximum, minimum = false).toString + ) ) + } def isOption(schema: SchemaType): Boolean = resolve(graph, schema).body match { case OptionType(_) => true case _ => false @@ -119,11 +138,11 @@ private object CanonicalJson { case S8Type(r) => integer(-128, 127, r) case S16Type(r) => integer(-32768, 32767, r) case S32Type(r) => integer(Int.MinValue, Int.MaxValue, r) - case S64Type(_) => wideInteger(unsigned = false) + case S64Type(r) => wideInteger(unsigned = false, restrictions = r) case U8Type(r) => integer(0, 255, r) case U16Type(r) => integer(0, 65535, r) case U32Type(r) => integer(0, BigInt("4294967295"), r) - case U64Type(_) => wideInteger(unsigned = true) + case U64Type(r) => wideInteger(unsigned = true, restrictions = r) case F32Type(r) => decimal(r) case F64Type(r) => decimal(r) case CharType => typed("string", "minLength" -> number(1), "maxLength" -> number(1)) @@ -231,7 +250,7 @@ private object CanonicalJson { case DurationType => typed( "object", - "properties" -> Json.Object("nanoseconds" -> wideInteger(unsigned = false)), + "properties" -> Json.Object("nanoseconds" -> wideInteger(unsigned = false, restrictions = None)), "required" -> Json.Array(Json.String("nanoseconds")), "additionalProperties" -> Json.Boolean(false), "title" -> Json.String("Duration in nanoseconds") @@ -240,7 +259,7 @@ private object CanonicalJson { typed( "object", "properties" -> Json.Object( - "mantissa" -> wideInteger(unsigned = false), + "mantissa" -> wideInteger(unsigned = false, restrictions = None), "scale" -> typed("integer"), "unit" -> typed("string") ), @@ -431,12 +450,14 @@ private object CanonicalJson { ResultValue(if (side == "ok") SchemaResult.Ok(packed) else SchemaResult.Err(packed)) case TextType(_) => val jsonFields = fields(json) + if (!jsonFields.keySet.subsetOf(Set("text", "language"))) fail("text JSON contains unknown fields") TextValue( string(jsonFields.getOrElse("text", fail("missing field 'text'"))), jsonFields.get("language").map(string) ) case BinaryType(_) => val jsonFields = fields(json) + if (!jsonFields.keySet.subsetOf(Set("bytes", "mimeType"))) fail("binary JSON contains unknown fields") BinaryValue( decodeBase64Url(string(jsonFields.getOrElse("bytes", fail("missing field 'bytes'")))), jsonFields.get("mimeType").map(string) @@ -452,6 +473,8 @@ private object CanonicalJson { DurationValue(canonicalLong(jsonFields("nanoseconds"), unsigned = false)) case QuantityType(_) => val jsonFields = fields(json) + if (jsonFields.keySet != Set("mantissa", "scale", "unit")) + fail("quantity JSON requires exactly 'mantissa', 'scale', and 'unit'") QuantityValueNode( QuantityValue( canonicalLong(jsonFields("mantissa"), unsigned = false), @@ -636,7 +659,9 @@ private object CanonicalJson { if (index + 3 < value.length) result += (((c & 3) << 6) | d).toByte index += 4 } - result.result() + val decoded = result.result() + if (encodeBase64Url(decoded) != value) fail("invalid base64url without padding") + decoded } } diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala index fb1109c613..a8b5645e24 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala @@ -97,8 +97,10 @@ private[reflection] object ReflectionConformanceCorpus { "errors/wide-wrong-json-type", "errors/duration-legacy-string", "errors/quantity-negative-zero", + "errors/quantity-extra-field", "errors/binary-noncanonical-base64", "json-schema/s64", + "json-schema/s64-restrictions", "json-schema/u64", "json-schema/duration-nanoseconds", "json-schema/quantity-mantissa", @@ -280,6 +282,20 @@ private[reflection] object ReflectionConformanceCorpus { "kind": "invalid-json" } }, + { + "id": "errors/quantity-extra-field", + "operation": "reject", + "fixture": "quantity", + "input": { + "mantissa": "1", + "scale": 0, + "unit": "m", + "extra": true + }, + "expected": { + "kind": "invalid-json" + } + }, { "id": "errors/binary-noncanonical-base64", "operation": "reject", @@ -292,6 +308,18 @@ private[reflection] object ReflectionConformanceCorpus { { "bytes": "-_8=", "mimeType": "application/octet-stream" + }, + { + "bytes": "-_9", + "mimeType": "application/octet-stream" + }, + { + "bytes": "A", + "mimeType": "application/octet-stream" + }, + { + "bytes": "-_8", + "mime_type": "application/octet-stream" } ], "expected": { @@ -311,6 +339,19 @@ private[reflection] object ReflectionConformanceCorpus { "x-golem-maximum": "9223372036854775807" } }, + { + "id": "json-schema/s64-restrictions", + "operation": "json-schema", + "fixture": "constrained-s64", + "path": "", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9007199254740993", + "x-golem-maximum": "9007199254740993" + } + }, { "id": "json-schema/u64", "operation": "json-schema", diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala index 9cceaa2f6a..55fef169eb 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala @@ -12,6 +12,7 @@ package golem.reflection import golem.schema.* import golem.schema.SchemaTypeBody.* +import golem.schema.validation.WellFormedness import zio.blocks.schema.json.Json import zio.test.* @@ -39,11 +40,27 @@ object ReflectionConformanceSpec extends ZIOSpecDefault { case other => throw new AssertionError(s"expected JSON number, got $other") } + private def boolean(value: Json): Boolean = value match { + case Json.Boolean(value) => value + case other => throw new AssertionError(s"expected JSON boolean, got $other") + } + private def field(name: String, body: SchemaType): NamedFieldType = NamedFieldType(name, body) private def fixture(name: String): SchemaRef = { val root = name match { - case "s64" => SchemaType(S64Type()) + case "s64" => SchemaType(S64Type()) + case "constrained-s64" => + SchemaType( + S64Type( + Some( + NumericRestrictions( + min = Some(NumericBound.Signed(-9007199254740993L)), + max = Some(NumericBound.Signed(9007199254740993L)) + ) + ) + ) + ) case "u64" => SchemaType(U64Type()) case "binary" => SchemaType(BinaryType(BinaryRestrictions.empty)) case "duration" => SchemaType(DurationType) @@ -222,7 +239,9 @@ object ReflectionConformanceSpec extends ZIOSpecDefault { Predef.assert(supported == expectedNames) Predef.assert(elements(fields(corpus)("restrictionKinds")).map(string) == expectedNames) case "graph" => - val inline = SchemaRef( + val expectedFields = fields(expected) + val referenced = fixture("optional-record") + val inline = SchemaRef( SchemaGraph( ListMap.empty, SchemaType( @@ -235,7 +254,10 @@ object ReflectionConformanceSpec extends ZIOSpecDefault { ) ) ) - Predef.assert(fixture("optional-record").packJson(Json.Object()) == inline.packJson(Json.Object())) + val valid = WellFormedness.validateGraph(referenced.graph).isRight + val equivalent = referenced.packJson(Json.Object()) == inline.packJson(Json.Object()) + Predef.assert(valid == boolean(expectedFields("valid"))) + Predef.assert(equivalent == boolean(expectedFields("equivalent"))) case other => throw new AssertionError(s"unknown semantic conformance fixture $other") } diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts index 0936e082b2..b75c8b3ffa 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts @@ -13,6 +13,7 @@ // limitations under the License. import type { + NumericRestrictions, SchemaGraph, SchemaType, SchemaTypeBody, @@ -440,7 +441,12 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record> 2)); if (value[index + 3] !== undefined) bytes.push(((c & 3) << 6) | d); } - return Uint8Array.from(bytes); + const result = Uint8Array.from(bytes); + if (bytesToBase64(result) !== value) fail(path, 'invalid base64url without padding'); + return result; } function rejectUnknownFields( @@ -959,3 +972,19 @@ function integerStringSchema( 'x-golem-maximum': max.toString(), }; } + +function restrictedIntegerBound( + bound: NumericRestrictions['min'] | undefined, + fallback: bigint, + side: 'min' | 'max', +): bigint { + if (bound === undefined || bound.tag === 'float-bits') return fallback; + const value = bound.val; + return side === 'min' + ? value > fallback + ? value + : fallback + : value < fallback + ? value + : fallback; +} diff --git a/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts index 1fa7b3034a..fbb4759d8b 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts @@ -117,6 +117,13 @@ function fixture(name: string): SchemaRef { switch (name) { case 's64': return schema(t.s64()); + case 'constrained-s64': + return schema( + t.s64({ + min: { tag: 'signed', val: -9_007_199_254_740_993n }, + max: { tag: 'signed', val: 9_007_199_254_740_993n }, + }), + ); case 'u64': return schema(t.u64()); case 'binary': @@ -124,7 +131,7 @@ function fixture(name: string): SchemaRef { case 'duration': return schema(t.duration()); case 'quantity': - return schema(t.quantity({ baseUnit: 'm', allowedUnits: [] })); + return schema(t.quantity({ baseUnit: 'm', allowedSuffixes: [] })); case 'optional-record': { const graph: SchemaGraph = { defs: new Map([['conformance.optional', { body: t.option(t.string()) }]]), @@ -177,8 +184,10 @@ function atPointer(value: JsonValue, pointer: string): JsonValue { function expectSubset(actual: JsonValue, expected: JsonValue): void { if (expected !== null && typeof expected === 'object' && !Array.isArray(expected)) { expect(actual).not.toBeNull(); + expect(typeof actual).toBe('object'); expect(Array.isArray(actual)).toBe(false); for (const [key, value] of Object.entries(expected)) { + expect(Object.prototype.hasOwnProperty.call(actual, key)).toBe(true); expectSubset((actual as Record)[key], value); } } else { diff --git a/test-data/reflection-conformance/v1.json b/test-data/reflection-conformance/v1.json index f61035c4b4..3449b33547 100644 --- a/test-data/reflection-conformance/v1.json +++ b/test-data/reflection-conformance/v1.json @@ -34,8 +34,10 @@ "errors/wide-wrong-json-type", "errors/duration-legacy-string", "errors/quantity-negative-zero", + "errors/quantity-extra-field", "errors/binary-noncanonical-base64", "json-schema/s64", + "json-schema/s64-restrictions", "json-schema/u64", "json-schema/duration-nanoseconds", "json-schema/quantity-mantissa", @@ -170,13 +172,23 @@ "input": { "mantissa": "-0", "scale": 0, "unit": "m" }, "expected": { "kind": "invalid-json" } }, + { + "id": "errors/quantity-extra-field", + "operation": "reject", + "fixture": "quantity", + "input": { "mantissa": "1", "scale": 0, "unit": "m", "extra": true }, + "expected": { "kind": "invalid-json" } + }, { "id": "errors/binary-noncanonical-base64", "operation": "reject", "fixture": "binary", "inputs": [ { "bytes": "+/8", "mimeType": "application/octet-stream" }, - { "bytes": "-_8=", "mimeType": "application/octet-stream" } + { "bytes": "-_8=", "mimeType": "application/octet-stream" }, + { "bytes": "-_9", "mimeType": "application/octet-stream" }, + { "bytes": "A", "mimeType": "application/octet-stream" }, + { "bytes": "-_8", "mime_type": "application/octet-stream" } ], "expected": { "kind": "invalid-json" } }, @@ -193,6 +205,19 @@ "x-golem-maximum": "9223372036854775807" } }, + { + "id": "json-schema/s64-restrictions", + "operation": "json-schema", + "fixture": "constrained-s64", + "path": "", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9007199254740993", + "x-golem-maximum": "9007199254740993" + } + }, { "id": "json-schema/u64", "operation": "json-schema", From 0c73bb018bcf2a08d99fabef5946da3cc00ca4e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 24 Sep 2026 11:54:12 +0200 Subject: [PATCH 26/37] moonbit: refresh generated example interfaces --- .../golem_moonbit_examples/pkg.generated.mbti | 14 ++++++++++++++ .../pkg.generated.mbti | 2 ++ 2 files changed, 16 insertions(+) diff --git a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/pkg.generated.mbti b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/pkg.generated.mbti index aeb9d3fc84..86a6d4074d 100644 --- a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/pkg.generated.mbti +++ b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/pkg.generated.mbti @@ -123,6 +123,7 @@ pub(all) struct CanonicalBigBoundClient { pub async fn CanonicalBigBoundClient::canonical_big_bound(Self, UInt64?) -> Result[Unit, @tool.ToolError[CanonicalGrepError]] pub fn CanonicalBigBoundClient::drop(Self) -> Unit pub fn CanonicalBigBoundClient::new() -> Self +pub fn CanonicalBigBoundClient::new_for(String) -> Self type CanonicalCommitError pub impl @tool-core.ToolErrorSchema for CanonicalCommitError @@ -149,6 +150,7 @@ pub async fn CanonicalGitClient::commit(Self, UInt, @schema.Path, Bool, Map[Stri pub fn CanonicalGitClient::drop(Self) -> Unit pub async fn CanonicalGitClient::log(Self, Int64?, @types.Datetime?, @types.Datetime?, Array[String], Array[String], Bool, Bool, Bool, Bool, Array[@schema.Path]) -> Result[Array[CanonicalLogEntry], @tool.ToolError[CanonicalLogError]] pub fn CanonicalGitClient::new() -> Self +pub fn CanonicalGitClient::new_for(String) -> Self pub fn CanonicalGitClient::remote(Self, UInt, @schema.Path, Bool, Map[String, String]) -> CanonicalGitRemoteClient pub fn CanonicalGitClient::stash(Self, UInt, @schema.Path) -> CanonicalGitStashClient @@ -188,6 +190,7 @@ pub(all) struct CanonicalGrepClient { pub fn CanonicalGrepClient::canonical_grep(Self, Bool, CanonicalGrepColorMode, String, Array[String], UInt?, Array[@schema.Path], @async-core.Stream[Byte]) -> Result[@tool.TypedToolInvocation[Array[CanonicalGrepHit], CanonicalGrepError], @tool.ToolError[CanonicalGrepError]] pub fn CanonicalGrepClient::drop(Self) -> Unit pub fn CanonicalGrepClient::new() -> Self +pub fn CanonicalGrepClient::new_for(String) -> Self pub async fn CanonicalGrepClient::replace(Self, Bool, CanonicalGrepColorMode, String, String, Array[@schema.Path]) -> Result[UInt64, @tool.ToolError[CanonicalGrepError]] pub(all) enum CanonicalGrepColorMode { @@ -320,6 +323,7 @@ pub(all) struct EmptyAgentInput { pub impl @schema.IntoSchema for EmptyAgentInput type MoonBitReflectionTest +pub fn MoonBitReflectionTest::canonical_values(Int64, UInt64, @schema.Duration, @schema.Quantity[ReflectionMeters], String?) -> String pub fn MoonBitReflectionTest::echo(String) -> String pub fn MoonBitReflectionTest::optional(String?) -> String @@ -328,6 +332,7 @@ pub async fn MoonBitStreaming::stream(String, @async-core.Stream[Byte], @tool.Pr type MoonBitToolReflectionCaller pub async fn MoonBitToolReflectionCaller::agent_round_trip(Self) -> String +pub async fn MoonBitToolReflectionCaller::canonical_round_trip(Self) -> String pub async fn MoonBitToolReflectionCaller::optional_round_trip(Self) -> String pub async fn MoonBitToolReflectionCaller::principal_round_trip(Self) -> String pub async fn MoonBitToolReflectionCaller::round_trip(Self) -> String @@ -336,6 +341,7 @@ pub(all) struct MoonBitToolReflectionCallerClient { client : @rpc.AgentClient } pub async fn MoonBitToolReflectionCallerClient::agent_round_trip(Self) -> String +pub async fn MoonBitToolReflectionCallerClient::canonical_round_trip(Self) -> String pub fn MoonBitToolReflectionCallerClient::drop(Self) -> Unit pub fn MoonBitToolReflectionCallerClient::get(String) -> Self raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::get_agent_id(Self) -> String raise @common.AgentError @@ -347,9 +353,11 @@ pub async fn MoonBitToolReflectionCallerClient::principal_round_trip(Self) -> St pub async fn MoonBitToolReflectionCallerClient::round_trip(Self) -> String pub fn MoonBitToolReflectionCallerClient::schedule_agent_round_trip(Self, @system-clock.Instant) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_cancelable_agent_round_trip(Self, @system-clock.Instant) -> @host.CancellationToken raise @common.AgentError +pub fn MoonBitToolReflectionCallerClient::schedule_cancelable_canonical_round_trip(Self, @system-clock.Instant) -> @host.CancellationToken raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_cancelable_optional_round_trip(Self, @system-clock.Instant) -> @host.CancellationToken raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_cancelable_principal_round_trip(Self, @system-clock.Instant) -> @host.CancellationToken raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_cancelable_round_trip(Self, @system-clock.Instant) -> @host.CancellationToken raise @common.AgentError +pub fn MoonBitToolReflectionCallerClient::schedule_canonical_round_trip(Self, @system-clock.Instant) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_optional_round_trip(Self, @system-clock.Instant) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_principal_round_trip(Self, @system-clock.Instant) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_round_trip(Self, @system-clock.Instant) -> Unit raise @common.AgentError @@ -357,6 +365,7 @@ pub async fn[T] MoonBitToolReflectionCallerClient::scoped(String, async (Self) - pub async fn[T] MoonBitToolReflectionCallerClient::scoped_get_phantom(String, @types.Uuid, async (Self) -> T) -> T pub async fn[T] MoonBitToolReflectionCallerClient::scoped_new_phantom(String, async (Self) -> T) -> T pub fn MoonBitToolReflectionCallerClient::trigger_agent_round_trip(Self) -> Unit raise @common.AgentError +pub fn MoonBitToolReflectionCallerClient::trigger_canonical_round_trip(Self) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::trigger_optional_round_trip(Self) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::trigger_principal_round_trip(Self) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::trigger_round_trip(Self) -> Unit raise @common.AgentError @@ -384,9 +393,11 @@ pub fn MoonBitToolStreamingCallerClient::trigger_marker_before_eof(Self, Bytes) pub(all) struct MoonbitReflectionTestClient { client : @tool.ToolClient } +pub async fn MoonbitReflectionTestClient::canonical_values(Self, Int64, UInt64, @schema.Duration, @schema.Quantity[ReflectionMeters], String?) -> Result[String, @tool.ToolError[@tool-core.NoToolError]] pub fn MoonbitReflectionTestClient::drop(Self) -> Unit pub async fn MoonbitReflectionTestClient::echo(Self, String) -> Result[String, @tool.ToolError[@tool-core.NoToolError]] pub fn MoonbitReflectionTestClient::new() -> Self +pub fn MoonbitReflectionTestClient::new_for(String) -> Self pub async fn MoonbitReflectionTestClient::optional(Self, String?) -> Result[String, @tool.ToolError[@tool-core.NoToolError]] pub(all) struct MoonbitStreamingClient { @@ -394,6 +405,7 @@ pub(all) struct MoonbitStreamingClient { } pub fn MoonbitStreamingClient::drop(Self) -> Unit pub fn MoonbitStreamingClient::new() -> Self +pub fn MoonbitStreamingClient::new_for(String) -> Self pub fn MoonbitStreamingClient::stream(Self, String, @async-core.Stream[Byte]) -> Result[@tool.TypedToolInvocation[UInt64, @tool-core.NoToolError], @tool.ToolError[@tool-core.NoToolError]] pub(all) enum Priority { @@ -404,6 +416,8 @@ pub(all) enum Priority { pub impl @schema.FromSchema for Priority pub impl @schema.IntoSchema for Priority +type ReflectionMeters + type RpcExampleAgent pub async fn RpcExampleAgent::counter_rpc_example(Self) -> UInt64 pub fn RpcExampleAgent::get_last_value(Self) -> UInt64 diff --git a/sdks/moonbit/golem_sdk_example1/golem_tool_middleware_examples/pkg.generated.mbti b/sdks/moonbit/golem_sdk_example1/golem_tool_middleware_examples/pkg.generated.mbti index 71672873eb..30aa7196a4 100644 --- a/sdks/moonbit/golem_sdk_example1/golem_tool_middleware_examples/pkg.generated.mbti +++ b/sdks/moonbit/golem_sdk_example1/golem_tool_middleware_examples/pkg.generated.mbti @@ -39,6 +39,7 @@ pub struct MessagesUnderlying { // private fields } pub async fn MessagesUnderlying::send(Self, String) -> Result[String, @tool-middleware.ToolInvokeError[@tool-core.NoToolError]] +pub async fn MessagesUnderlying::start_send(Self, String) -> Result[@tool-middleware.UnderlyingInvocation[String, @tool-core.NoToolError], @tool-middleware.ToolInvokeError[@tool-core.NoToolError]] type PublicFiles pub fn PublicFiles::read(String) -> String @@ -50,6 +51,7 @@ pub struct StorageUnderlying { // private fields } pub async fn StorageUnderlying::fetch(Self, String) -> Result[String, @tool-middleware.ToolInvokeError[@tool-core.NoToolError]] +pub async fn StorageUnderlying::start_fetch(Self, String) -> Result[@tool-middleware.UnderlyingInvocation[String, @tool-core.NoToolError], @tool-middleware.ToolInvokeError[@tool-core.NoToolError]] // Type aliases From feee9ba6586796b983036b1120a6c31f7490d798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 24 Sep 2026 14:14:55 +0200 Subject: [PATCH 27/37] fix: align reflection JSON schemas with canonical validation --- golem-schema/src/schema/render/json_schema.rs | 134 ++++++++++++++---- .../src/internal/reflection/schemaRender.ts | 27 ++-- sdks/effect/test/schema-ref.test.ts | 26 ++++ .../golem_sdk/reflection/json_schema.mbt | 3 + .../reflection/schema_ref_wbtest.mbt | 26 ++++ .../scala/golem/reflection/SchemaRef.scala | 8 +- .../golem/reflection/SchemaRefSpec.scala | 47 ++++-- .../golem-ts-sdk/src/schema/render.ts | 53 +++++-- .../golem-ts-sdk/tests/schema-ref.test.ts | 55 +++++++ 9 files changed, 321 insertions(+), 58 deletions(-) diff --git a/golem-schema/src/schema/render/json_schema.rs b/golem-schema/src/schema/render/json_schema.rs index c1c21e15a7..7753b824d9 100644 --- a/golem-schema/src/schema/render/json_schema.rs +++ b/golem-schema/src/schema/render/json_schema.rs @@ -27,6 +27,8 @@ use std::collections::{HashMap, HashSet}; const JSON_SCHEMA_DRAFT: &str = "https://json-schema.org/draft/2020-12/schema"; const MIME_TYPE_PATTERN: &str = "^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$"; +const BASE64URL_PATTERN: &str = + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$"; /// Configuration for the JSON Schema renderer. /// @@ -666,16 +668,31 @@ pub(super) fn render_type( SchemaType::Ref { id, .. } => obj([("$ref", Value::String(ref_pointer(id, root)))]), SchemaType::Bool { .. } => obj([("type", Value::String("boolean".to_string()))]), - SchemaType::S8 { .. } => integer_schema(i8::MIN as i64, i8::MAX as i64), - SchemaType::S16 { .. } => integer_schema(i16::MIN as i64, i16::MAX as i64), - SchemaType::S32 { .. } => integer_schema(i32::MIN as i64, i32::MAX as i64), + SchemaType::S8 { restrictions, .. } => { + signed_integer_schema(i8::MIN as i64, i8::MAX as i64, restrictions.as_ref()) + } + SchemaType::S16 { restrictions, .. } => { + signed_integer_schema(i16::MIN as i64, i16::MAX as i64, restrictions.as_ref()) + } + SchemaType::S32 { restrictions, .. } => { + signed_integer_schema(i32::MIN as i64, i32::MAX as i64, restrictions.as_ref()) + } SchemaType::S64 { restrictions, .. } => signed_64_schema(restrictions.as_ref()), - SchemaType::U8 { .. } => integer_schema(0, u8::MAX as i64), - SchemaType::U16 { .. } => integer_schema(0, u16::MAX as i64), - SchemaType::U32 { .. } => integer_schema(0, u32::MAX as i64), + SchemaType::U8 { restrictions, .. } => { + unsigned_integer_schema(0, u8::MAX as u64, restrictions.as_ref()) + } + SchemaType::U16 { restrictions, .. } => { + unsigned_integer_schema(0, u16::MAX as u64, restrictions.as_ref()) + } + SchemaType::U32 { restrictions, .. } => { + unsigned_integer_schema(0, u32::MAX as u64, restrictions.as_ref()) + } SchemaType::U64 { restrictions, .. } => unsigned_64_schema(restrictions.as_ref()), - SchemaType::F32 { .. } | SchemaType::F64 { .. } => { - obj([("type", Value::String("number".to_string()))]) + SchemaType::F32 { restrictions, .. } => { + float_schema(-(f32::MAX as f64), f32::MAX as f64, restrictions.as_ref()) + } + SchemaType::F64 { restrictions, .. } => { + float_schema(-f64::MAX, f64::MAX, restrictions.as_ref()) } SchemaType::Char { .. } => obj([ ("type", Value::String("string".to_string())), @@ -907,14 +924,74 @@ pub(super) fn ref_to_def_key(key: &str) -> String { format!("#/$defs/{}", escape_pointer_token(key)) } -fn integer_schema(min: i64, max: i64) -> Value { +fn signed_integer_schema(min: i64, max: i64, restrictions: Option<&NumericRestrictions>) -> Value { + let minimum = restrictions + .and_then(|value| value.min) + .and_then(|bound| match bound { + NumericBound::Signed(value) => Some(value), + _ => None, + }) + .unwrap_or(min) + .max(min); + let maximum = restrictions + .and_then(|value| value.max) + .and_then(|bound| match bound { + NumericBound::Signed(value) => Some(value), + _ => None, + }) + .unwrap_or(max) + .min(max); + obj([ + ("type", Value::String("integer".to_string())), + ("minimum", Value::Number(Number::from(minimum))), + ("maximum", Value::Number(Number::from(maximum))), + ]) +} + +fn unsigned_integer_schema( + min: u64, + max: u64, + restrictions: Option<&NumericRestrictions>, +) -> Value { + let minimum = restrictions + .and_then(|value| value.min) + .and_then(|bound| match bound { + NumericBound::Unsigned(value) => Some(value), + _ => None, + }) + .unwrap_or(min) + .max(min); + let maximum = restrictions + .and_then(|value| value.max) + .and_then(|bound| match bound { + NumericBound::Unsigned(value) => Some(value), + _ => None, + }) + .unwrap_or(max) + .min(max); obj([ ("type", Value::String("integer".to_string())), - ("minimum", Value::Number(Number::from(min))), - ("maximum", Value::Number(Number::from(max))), + ("minimum", Value::Number(Number::from(minimum))), + ("maximum", Value::Number(Number::from(maximum))), ]) } +fn float_schema(min: f64, max: f64, restrictions: Option<&NumericRestrictions>) -> Value { + let mut schema = Map::new(); + schema.insert("type".to_string(), Value::String("number".to_string())); + if let Some(NumericBound::FloatBits(bits)) = restrictions.and_then(|value| value.min) + && let Some(value) = Number::from_f64(f64::from_bits(bits).max(min)) + { + schema.insert("minimum".to_string(), Value::Number(value)); + } + if let Some(NumericBound::FloatBits(bits)) = restrictions.and_then(|value| value.max) + && let Some(value) = Number::from_f64(f64::from_bits(bits).min(max)) + { + schema.insert("maximum".to_string(), Value::Number(value)); + } + Value::Object(schema) +} + fn unsigned_64_schema(restrictions: Option<&NumericRestrictions>) -> Value { let minimum = restrictions .and_then(|value| value.min) @@ -1064,10 +1141,15 @@ fn text_schema(restrictions: &TextRestrictions) -> Map { } let mut properties = Map::new(); properties.insert("text".to_string(), Value::Object(text_field)); - properties.insert( - "language".to_string(), - obj([("type", Value::String("string".to_string()))]), - ); + let mut language_field = Map::new(); + language_field.insert("type".to_string(), Value::String("string".to_string())); + if let Some(langs) = &restrictions.languages { + language_field.insert( + "enum".to_string(), + Value::Array(langs.iter().cloned().map(Value::String).collect()), + ); + } + properties.insert("language".to_string(), Value::Object(language_field)); let mut m = Map::new(); m.insert("type".to_string(), Value::String("object".to_string())); m.insert("properties".to_string(), Value::Object(properties)); @@ -1076,12 +1158,6 @@ fn text_schema(restrictions: &TextRestrictions) -> Map { Value::Array(vec![Value::String("text".to_string())]), ); m.insert("additionalProperties".to_string(), Value::Bool(false)); - if let Some(langs) = &restrictions.languages { - m.insert( - "description".to_string(), - Value::String(format!("Allowed languages: {}", langs.join(", "))), - ); - } m } @@ -1096,6 +1172,10 @@ fn binary_schema(restrictions: &BinaryRestrictions) -> Map { "contentEncoding".to_string(), Value::String("base64url".to_string()), ); + bytes_field.insert( + "pattern".to_string(), + Value::String(BASE64URL_PATTERN.to_string()), + ); if let Some(min) = restrictions.min_bytes { bytes_field.insert( "minLength".to_string(), @@ -1114,6 +1194,12 @@ fn binary_schema(restrictions: &BinaryRestrictions) -> Map { "pattern".to_string(), Value::String(MIME_TYPE_PATTERN.to_string()), ); + if let Some(mimes) = &restrictions.mime_types { + mime_field.insert( + "enum".to_string(), + Value::Array(mimes.iter().cloned().map(Value::String).collect()), + ); + } let mut properties = Map::new(); properties.insert("bytes".to_string(), Value::Object(bytes_field)); properties.insert("mimeType".to_string(), Value::Object(mime_field)); @@ -1125,12 +1211,6 @@ fn binary_schema(restrictions: &BinaryRestrictions) -> Map { Value::Array(vec![Value::String("bytes".to_string())]), ); m.insert("additionalProperties".to_string(), Value::Bool(false)); - if let Some(mimes) = &restrictions.mime_types { - m.insert( - "description".to_string(), - Value::String(format!("Allowed MIME types: {}", mimes.join(", "))), - ); - } m } diff --git a/sdks/effect/src/internal/reflection/schemaRender.ts b/sdks/effect/src/internal/reflection/schemaRender.ts index 083e30524d..eb45ea8d9a 100644 --- a/sdks/effect/src/internal/reflection/schemaRender.ts +++ b/sdks/effect/src/internal/reflection/schemaRender.ts @@ -622,12 +622,17 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record { } }) + it("renders enforceable rich-value allowlists and canonical base64url bytes", () => { + const rendered = ref( + t.record([ + field("text", t.text({ languages: ["en", "de"] })), + field("binary", t.binary({ mimeTypes: ["image/png"] })), + ]), + ).toJsonSchema() + expect(rendered).toMatchObject({ + properties: { + text: { properties: { language: { enum: ["en", "de"] } } }, + binary: { properties: { mimeType: { enum: ["image/png"] } } }, + }, + }) + const pattern = ( + rendered as { + properties: { binary: { properties: { bytes: { pattern: string } } } } + } + ).properties.binary.properties.bytes.pattern + expect(pattern).toBe( + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + ) + const regex = new RegExp(pattern, "u") + for (const valid of ["", "AQ", "AQI", "AQID", "-_8"]) expect(regex.test(valid)).toBe(true) + for (const invalid of ["+/8", "AQ==", "-_9", "A"]) expect(regex.test(invalid)).toBe(false) + }) + it("matches canonical rich JSON forms, restrictions, and lossless integer ranges", () => { const schema = ref( t.record([ diff --git a/sdks/moonbit/golem_sdk/reflection/json_schema.mbt b/sdks/moonbit/golem_sdk/reflection/json_schema.mbt index 1e751d6678..d462cdf23a 100644 --- a/sdks/moonbit/golem_sdk/reflection/json_schema.mbt +++ b/sdks/moonbit/golem_sdk/reflection/json_schema.mbt @@ -483,6 +483,9 @@ fn schema_json(graph : @model.SchemaGraph, schema : @model.SchemaType) -> Json { let bytes : Map[String, Json] = { "type": Json::string("string"), "contentEncoding": Json::string("base64url"), + "pattern": Json::string( + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + ), } match restrictions.min_bytes { Some(value) => diff --git a/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt index 5cc03220ea..f87983ea16 100644 --- a/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt @@ -113,6 +113,9 @@ test "JSON Schema retains text and binary constraints" { "bytes": Json::object({ "type": Json::string("string"), "contentEncoding": Json::string("base64url"), + "pattern": Json::string( + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + ), "minLength": Json::number(4.0), "maxLength": Json::number(8.0), }), @@ -125,6 +128,29 @@ test "JSON Schema retains text and binary constraints" { "additionalProperties": Json::boolean(false), }), ) + let pattern = match rendered { + Object(fields) => + match fields.get("properties") { + Some(Object(properties)) => + match properties.get("bytes") { + Some(Object(bytes)) => + match bytes.get("pattern") { + Some(String(value)) => value + _ => fail("expected binary pattern string") + } + _ => fail("expected binary bytes schema") + } + _ => fail("expected binary properties") + } + _ => fail("expected binary JSON Schema object") + } + let regex = @string.Regex::Regex(pattern.replace_all(old="_-]", new="_\\-]")) + for valid in ["", "AQ", "AQI", "AQID", "-_8"] { + assert_true(regex.execute(valid) is Some(_)) + } + for invalid in ["+/8", "AQ==", "-_9", "A"] { + assert_true(regex.execute(invalid) is None) + } } ///| diff --git a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala index d6d95bb3ec..70ef59a55c 100644 --- a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala @@ -238,7 +238,13 @@ private object CanonicalJson { typed( "object", "properties" -> Json.Object( - "bytes" -> typed("string", ("contentEncoding" -> Json.String("base64url")) +: byteRestrictions: _*), + "bytes" -> typed( + "string", + ("contentEncoding" -> Json.String("base64url")) +: + ("pattern" -> Json.String( + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$" + )) +: byteRestrictions: _* + ), "mimeType" -> mimeType ), "required" -> Json.Array(Json.String("bytes")), diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala index 0d1a73d3f0..01f90e4dc8 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala @@ -113,28 +113,59 @@ object SchemaRefSpec extends ZIOSpecDefault { ), NamedFieldType( "message", - SchemaType(TextType(TextRestrictions(minLength = Some(12), regex = Some("^https://")))) + SchemaType( + TextType( + TextRestrictions( + languages = Some(List("en", "de")), + minLength = Some(12), + regex = Some("^https://") + ) + ) + ) ), NamedFieldType( "content", - SchemaType(BinaryType(BinaryRestrictions(minBytes = Some(3), maxBytes = Some(6)))) + SchemaType( + BinaryType( + BinaryRestrictions( + mimeTypes = Some(List("image/png")), + minBytes = Some(3), + maxBytes = Some(6) + ) + ) + ) ) ) ) ) ) ) - val rendered = restricted.toJsonSchema() - val properties = rendered.get("properties").one.toOption.get - val count = properties.get("count").one.toOption.get - val text = properties.get("message").one.toOption.get.get("properties").one.toOption.get.get("text").one - val bytes = properties.get("content").one.toOption.get.get("properties").one.toOption.get.get("bytes").one + val rendered = restricted.toJsonSchema() + val properties = rendered.get("properties").one.toOption.get + val count = properties.get("count").one.toOption.get + val textProperties = properties.get("message").one.toOption.get.get("properties").one.toOption.get + val text = textProperties.get("text").one + val binaryProperties = properties.get("content").one.toOption.get.get("properties").one.toOption.get + val bytes = binaryProperties.get("bytes").one + val pattern = bytes.flatMap(_.get("pattern").one) match { + case Right(Json.String(value)) => value + case other => throw new AssertionError(s"expected binary pattern, got $other") + } + val canonical = List("", "AQ", "AQI", "AQID", "-_8").forall(value => pattern.r.pattern.matcher(value).matches()) + val nonCanonical = List("+/8", "AQ==", "-_9", "A").forall(value => !pattern.r.pattern.matcher(value).matches()) assertTrue( count.get("maximum").one == Right(Json.Number(BigDecimal(3))), text.flatMap(_.get("minLength").one) == Right(Json.Number(BigDecimal(12))), text.flatMap(_.get("pattern").one) == Right(Json.String("^https://")), + textProperties.get("language").one.flatMap(_.get("enum").one) == + Right(Json.Array(Json.String("en"), Json.String("de"))), bytes.flatMap(_.get("minLength").one) == Right(Json.Number(BigDecimal(4))), - bytes.flatMap(_.get("maxLength").one) == Right(Json.Number(BigDecimal(8))) + bytes.flatMap(_.get("maxLength").one) == Right(Json.Number(BigDecimal(8))), + pattern == "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + canonical, + nonCanonical, + binaryProperties.get("mimeType").one.flatMap(_.get("enum").one) == + Right(Json.Array(Json.String("image/png"))) ) }, test("union export keeps discriminator and branch body while packing enforces the rule") { diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts index b75c8b3ffa..6c38d65698 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts @@ -12,12 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { - NumericRestrictions, - SchemaGraph, - SchemaType, - SchemaTypeBody, - SchemaValue, +import { + floatFromBits, + type NumericRestrictions, + type SchemaGraph, + type SchemaType, + type SchemaTypeBody, + type SchemaValue, } from '../internal/schema-model'; import { datetimeFromISOString, datetimeToISOString } from '../bridge/schema'; import { SchemaRenderError, type JsonValue } from './ref'; @@ -482,12 +483,17 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record { }); }); + it('renders declared bounds for every narrow integer and float family', () => { + for (const type of [ + t.s8({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), + t.s16({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), + t.s32({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), + t.u8({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), + t.u16({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), + t.u32({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), + ]) { + expect(schema(type).toJsonSchema()).toMatchObject({ + minimum: type.body.tag.startsWith('s') ? -12 : 2, + maximum: 12, + }); + } + + for (const type of [ + t.f32({ + min: { tag: 'float-bits', val: 0xbff0000000000000n }, + max: { tag: 'float-bits', val: 0x3fe0000000000000n }, + }), + t.f64({ + min: { tag: 'float-bits', val: 0xbff0000000000000n }, + max: { tag: 'float-bits', val: 0x3fe0000000000000n }, + }), + ]) { + expect(schema(type).toJsonSchema()).toMatchObject({ minimum: -1, maximum: 0.5 }); + } + }); + + it('renders enforceable rich-value allowlists and canonical base64url bytes', () => { + const rendered = schema( + t.record([ + field('text', schemaType({ tag: 'text', restrictions: { languages: ['en', 'de'] } })), + field('binary', schemaType({ tag: 'binary', restrictions: { mimeTypes: ['image/png'] } })), + ]), + ).toJsonSchema(); + expect(rendered).toMatchObject({ + properties: { + text: { properties: { language: { enum: ['en', 'de'] } } }, + binary: { properties: { mimeType: { enum: ['image/png'] } } }, + }, + }); + const pattern = ( + rendered as { + properties: { binary: { properties: { bytes: { pattern: string } } } }; + } + ).properties.binary.properties.bytes.pattern; + expect(pattern).toBe( + '^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$', + ); + const regex = new RegExp(pattern, 'u'); + for (const valid of ['', 'AQ', 'AQI', 'AQID', '-_8']) expect(regex.test(valid)).toBe(true); + for (const invalid of ['+/8', 'AQ==', '-_9', 'A']) expect(regex.test(invalid)).toBe(false); + }); + it('makes reflection-only unsupported leaves unsatisfiable', () => { for (const type of [ t.secret(t.string()), From 72719aa6aaf2c18a9d87117d00bf049189d67df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 24 Sep 2026 14:15:22 +0200 Subject: [PATCH 28/37] test: enforce reflection rejection categories --- golem-schema/src/schema/render/tests.rs | 167 +++++++++++++++++- scripts/sync-reflection-conformance.mjs | 8 + .../test/reflection-conformance.test.ts | 50 +++++- .../reflection_conformance_corpus_wbtest.mbt | 2 +- .../reflection_conformance_wbtest.mbt | 70 +++++++- .../rust/golem-rust/src/agentic/reflection.rs | 47 ++++- .../ReflectionConformanceCorpus.scala | 83 +++++++++ .../ReflectionConformanceSpec.scala | 53 +++++- .../tests/reflection-conformance.test.ts | 53 +++++- test-data/reflection-conformance/v1.json | 73 ++++++++ 10 files changed, 579 insertions(+), 27 deletions(-) diff --git a/golem-schema/src/schema/render/tests.rs b/golem-schema/src/schema/render/tests.rs index 90aab7898f..5280339f48 100644 --- a/golem-schema/src/schema/render/tests.rs +++ b/golem-schema/src/schema/render/tests.rs @@ -346,6 +346,131 @@ fn reflection_json_schema_rejects_unrepresentable_leaves() { } } +#[test] +fn numeric_json_schemas_apply_every_narrow_and_float_bound() { + let signed = |min, max| { + Some(NumericRestrictions { + min: Some(NumericBound::Signed(min)), + max: Some(NumericBound::Signed(max)), + unit: None, + }) + }; + let unsigned = |min, max| { + Some(NumericRestrictions { + min: Some(NumericBound::Unsigned(min)), + max: Some(NumericBound::Unsigned(max)), + unit: None, + }) + }; + let cases = [ + SchemaType::S8 { + restrictions: signed(-12, 12), + metadata: MetadataEnvelope::default(), + }, + SchemaType::S16 { + restrictions: signed(-1_200, 1_200), + metadata: MetadataEnvelope::default(), + }, + SchemaType::S32 { + restrictions: signed(-120_000, 120_000), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U8 { + restrictions: unsigned(12, 120), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U16 { + restrictions: unsigned(1_200, 12_000), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U32 { + restrictions: unsigned(120_000, 1_200_000), + metadata: MetadataEnvelope::default(), + }, + ]; + let expected = [ + (-12, 12), + (-1_200, 1_200), + (-120_000, 120_000), + (12, 120), + (1_200, 12_000), + (120_000, 1_200_000), + ]; + for (ty, (min, max)) in cases.into_iter().zip(expected) { + let schema = to_json_schema(&SchemaGraph::anonymous(ty.clone()), &ty); + assert_eq!(schema["minimum"], min); + assert_eq!(schema["maximum"], max); + } + + for (ty, min, max) in [ + ( + SchemaType::F32 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::float(-1.5).unwrap()), + max: Some(NumericBound::float(2.5).unwrap()), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + -1.5, + 2.5, + ), + ( + SchemaType::F64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::float(-3.5).unwrap()), + max: Some(NumericBound::float(4.5).unwrap()), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + -3.5, + 4.5, + ), + ] { + let schema = to_json_schema(&SchemaGraph::anonymous(ty.clone()), &ty); + assert_eq!(schema["minimum"], min); + assert_eq!(schema["maximum"], max); + } +} + +#[test] +fn rich_json_schemas_enforce_allowlists_and_canonical_base64url() { + let text = SchemaType::text(TextRestrictions { + languages: Some(vec!["en".to_string(), "de".to_string()]), + ..Default::default() + }); + let text_schema = to_json_schema(&SchemaGraph::anonymous(text.clone()), &text); + assert_eq!( + text_schema["properties"]["language"]["enum"], + json!(["en", "de"]) + ); + + let binary = SchemaType::binary(BinaryRestrictions { + mime_types: Some(vec!["image/png".to_string()]), + ..Default::default() + }); + let binary_schema = to_json_schema(&SchemaGraph::anonymous(binary.clone()), &binary); + assert_eq!( + binary_schema["properties"]["mimeType"]["enum"], + json!(["image/png"]) + ); + let pattern = binary_schema["properties"]["bytes"]["pattern"] + .as_str() + .expect("binary bytes pattern"); + assert_eq!( + pattern, + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$" + ); + let regex = regex::Regex::new(pattern).expect("canonical base64url regex"); + for valid in ["", "AQ", "AQI", "AQID", "-_8"] { + assert!(regex.is_match(valid), "{valid} must match"); + } + for invalid in ["+/8", "AQ==", "-_9", "A"] { + assert!(!regex.is_match(invalid), "{invalid} must not match"); + } +} + #[test] fn malformed_json_and_schema_values_are_typed_errors() { let ty = SchemaType::record(vec![NamedFieldType { @@ -395,12 +520,34 @@ fn conformance_fixture(name: &str) -> SchemaGraph { ]), "constrained-u32" => SchemaType::U32 { restrictions: Some(NumericRestrictions { - min: None, + min: Some(NumericBound::Unsigned(2)), max: Some(NumericBound::Unsigned(10)), unit: None, }), metadata: MetadataEnvelope::default(), }, + "constrained-f64" => SchemaType::F64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::float(-1.5).unwrap()), + max: Some(NumericBound::float(2.5).unwrap()), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + "constrained-text" => SchemaType::text(TextRestrictions { + languages: Some(vec!["en".to_string(), "de".to_string()]), + min_length: Some(2), + max_length: Some(8), + regex: Some("^[a-z]+$".to_string()), + }), + "constrained-binary" => SchemaType::binary(BinaryRestrictions { + mime_types: Some(vec![ + "image/png".to_string(), + "application/octet-stream".to_string(), + ]), + min_bytes: Some(2), + max_bytes: Some(4), + }), "result" => SchemaType::result(ResultSpec { ok: Some(Box::new(SchemaType::string())), err: Some(Box::new(SchemaType::u32())), @@ -608,14 +755,18 @@ fn reflection_conformance_corpus() { .cloned() .unwrap_or_else(|| vec![case["input"].clone()]); for input in inputs { - let decoded = from_json_value(&graph, &graph.root, &input); - let rejected = match fixture { - "constrained-u32" => decoded - .as_ref() - .is_ok_and(|value| validate_value(&graph, &graph.root, value).is_err()), - _ => decoded.is_err(), + let actual = match from_json_value(&graph, &graph.root, &input) { + Err(_) => "invalid-json", + Ok(value) if validate_value(&graph, &graph.root, &value).is_err() => { + "constraint-violation" + } + Ok(value) => panic!("{id} accepted {input} as {value:?}"), }; - assert!(rejected, "{id} accepted {input}"); + assert_eq!( + actual, + expected["kind"].as_str().expect("reject kind"), + "{id}" + ); } } "json-schema" => { diff --git a/scripts/sync-reflection-conformance.mjs b/scripts/sync-reflection-conformance.mjs index e357abe864..e0a82c6ab4 100644 --- a/scripts/sync-reflection-conformance.mjs +++ b/scripts/sync-reflection-conformance.mjs @@ -32,6 +32,14 @@ for (const testCase of corpus.cases) { for (const field of ['id', 'operation', 'fixture', 'expected']) { if (!(field in testCase)) throw new Error(`${testCase.id ?? ''} is missing ${field}`); } + if (testCase.operation === 'reject') { + const kind = testCase.expected?.kind; + if (kind !== 'invalid-json' && kind !== 'constraint-violation') { + throw new Error( + `${testCase.id} has missing or unknown reject kind ${JSON.stringify(kind)}`, + ); + } + } } const scala = `/* diff --git a/sdks/effect/test/reflection-conformance.test.ts b/sdks/effect/test/reflection-conformance.test.ts index c5994945d8..03d40ec2f6 100644 --- a/sdks/effect/test/reflection-conformance.test.ts +++ b/sdks/effect/test/reflection-conformance.test.ts @@ -138,7 +138,36 @@ function fixture(name: string): SchemaRef { case "config-entry": return ref(t.record([field("path", t.list(t.string())), field("value", t.s64())])) case "constrained-u32": - return ref(t.u32({ max: { tag: "unsigned", val: 10n } })) + return ref( + t.u32({ + min: { tag: "unsigned", val: 2n }, + max: { tag: "unsigned", val: 10n }, + }), + ) + case "constrained-f64": + return ref( + t.f64({ + min: { tag: "float-bits", val: 0xbff8000000000000n }, + max: { tag: "float-bits", val: 0x4004000000000000n }, + }), + ) + case "constrained-text": + return ref( + t.text({ + languages: ["en", "de"], + minLength: 2, + maxLength: 8, + regex: "^[a-z]+$", + }), + ) + case "constrained-binary": + return ref( + t.binary({ + mimeTypes: ["image/png", "application/octet-stream"], + minBytes: 2, + maxBytes: 4, + }), + ) case "result": return ref(t.result(t.string(), t.u32())) case "custom-error": @@ -235,11 +264,26 @@ describe("reflection conformance corpus", () => { ) break } - case "reject": + case "reject": { for (const input of testCase.inputs ?? [testCase.input!]) { - expect(fixture(testCase.fixture).validateJson(input).success, testCase.id).toBe(false) + const schema = fixture(testCase.fixture) + let packed: ReturnType + try { + packed = schema.packJson(input) + } catch { + expect("invalid-json", testCase.id).toBe( + (testCase.expected as { readonly kind: string }).kind, + ) + continue + } + if (schema.validateValue(packed).success) + throw new Error(`${testCase.id} accepted ${JSON.stringify(input)}`) + expect("constraint-violation", testCase.id).toBe( + (testCase.expected as { readonly kind: string }).kind, + ) } break + } case "json-schema": expectSubset( atPointer(fixture(testCase.fixture).toJsonSchema(), testCase.path ?? ""), diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt index 31633d3a20..cf9ff7c5ee 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt @@ -9,4 +9,4 @@ // Generated by scripts/sync-reflection-conformance.mjs. Do not edit. ///| -let reflection_conformance_corpus_json : String = "{\n \"version\": \"1.0.0\",\n \"schemaKinds\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ],\n \"restrictionKinds\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ],\n \"caseIds\": [\n \"canonical/s64-min\",\n \"canonical/s64-max\",\n \"canonical/u64-max\",\n \"canonical/duration-min\",\n \"canonical/duration-max\",\n \"canonical/quantity-wide-mantissa\",\n \"canonical/omitted-options\",\n \"canonical/binary-mime\",\n \"errors/s64-plus\",\n \"errors/s64-leading-zero\",\n \"errors/s64-negative-zero\",\n \"errors/s64-overflow\",\n \"errors/u64-negative\",\n \"errors/u64-overflow\",\n \"errors/wide-wrong-json-type\",\n \"errors/duration-legacy-string\",\n \"errors/quantity-negative-zero\",\n \"errors/quantity-extra-field\",\n \"errors/binary-noncanonical-base64\",\n \"json-schema/s64\",\n \"json-schema/s64-restrictions\",\n \"json-schema/u64\",\n \"json-schema/duration-nanoseconds\",\n \"json-schema/quantity-mantissa\",\n \"json-schema/optional-required\",\n \"json-schema/unsupported-leaves\",\n \"schema/all-kinds\",\n \"schema/all-restrictions\",\n \"graph/references-validation-equivalence\",\n \"tool/canonical-input\",\n \"config/canonical-entry\",\n \"constraints/rejected-value\",\n \"compatibility/result\",\n \"compatibility/custom-error\"\n ],\n \"cases\": [\n {\n \"id\": \"canonical/s64-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"-9223372036854775808\",\n \"expected\": \"-9223372036854775808\"\n },\n {\n \"id\": \"canonical/s64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775807\",\n \"expected\": \"9223372036854775807\"\n },\n {\n \"id\": \"canonical/u64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551615\",\n \"expected\": \"18446744073709551615\"\n },\n {\n \"id\": \"canonical/duration-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"-9223372036854775808\"\n },\n \"expected\": {\n \"nanoseconds\": \"-9223372036854775808\"\n }\n },\n {\n \"id\": \"canonical/duration-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"9223372036854775807\"\n },\n \"expected\": {\n \"nanoseconds\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"canonical/quantity-wide-mantissa\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n }\n },\n {\n \"id\": \"canonical/omitted-options\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"optional-record\",\n \"input\": {},\n \"expected\": {\n \"direct\": null,\n \"referenced\": null\n }\n },\n {\n \"id\": \"canonical/binary-mime\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"binary\",\n \"input\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n },\n \"expected\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n }\n },\n {\n \"id\": \"errors/s64-plus\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"+1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-leading-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"01\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"-0\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775808\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-negative\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"-1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551616\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/wide-wrong-json-type\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": 1,\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/duration-legacy-string\",\n \"operation\": \"reject\",\n \"fixture\": \"duration\",\n \"input\": \"PT1S\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-0\",\n \"scale\": 0,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-extra-field\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"1\",\n \"scale\": 0,\n \"unit\": \"m\",\n \"extra\": true\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/binary-noncanonical-base64\",\n \"operation\": \"reject\",\n \"fixture\": \"binary\",\n \"inputs\": [\n {\n \"bytes\": \"+/8\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8=\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_9\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"A\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8\",\n \"mime_type\": \"application/octet-stream\"\n }\n ],\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"json-schema/s64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/s64-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9007199254740993\",\n \"x-golem-maximum\": \"9007199254740993\"\n }\n },\n {\n \"id\": \"json-schema/u64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"u64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"uint64\",\n \"pattern\": \"^(?:0|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"0\",\n \"x-golem-maximum\": \"18446744073709551615\"\n }\n },\n {\n \"id\": \"json-schema/duration-nanoseconds\",\n \"operation\": \"json-schema\",\n \"fixture\": \"duration\",\n \"path\": \"/properties/nanoseconds\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/quantity-mantissa\",\n \"operation\": \"json-schema\",\n \"fixture\": \"quantity\",\n \"path\": \"/properties/mantissa\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/optional-required\",\n \"operation\": \"json-schema\",\n \"fixture\": \"optional-record\",\n \"path\": \"/required\",\n \"expected\": []\n },\n {\n \"id\": \"json-schema/unsupported-leaves\",\n \"operation\": \"semantic\",\n \"fixture\": \"unsupported-leaves\",\n \"expected\": {\n \"count\": 5,\n \"schema\": {\n \"not\": {}\n }\n }\n },\n {\n \"id\": \"schema/all-kinds\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-kinds\",\n \"expected\": {\n \"names\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ]\n }\n },\n {\n \"id\": \"schema/all-restrictions\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-restrictions\",\n \"expected\": {\n \"names\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ]\n }\n },\n {\n \"id\": \"graph/references-validation-equivalence\",\n \"operation\": \"semantic\",\n \"fixture\": \"graph\",\n \"expected\": {\n \"valid\": true,\n \"equivalent\": true\n }\n },\n {\n \"id\": \"tool/canonical-input\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"tool-input\",\n \"input\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ]\n },\n \"expected\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ],\n \"ignoreCase\": null\n }\n },\n {\n \"id\": \"config/canonical-entry\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"config-entry\",\n \"input\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n },\n \"expected\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"constraints/rejected-value\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-u32\",\n \"input\": 11,\n \"expected\": {\n \"kind\": \"constraint-violation\"\n }\n },\n {\n \"id\": \"compatibility/result\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"result\",\n \"input\": {\n \"ok\": \"ready\"\n },\n \"expected\": {\n \"ok\": \"ready\"\n }\n },\n {\n \"id\": \"compatibility/custom-error\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"custom-error\",\n \"input\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n },\n \"expected\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n }\n }\n ]\n}\n" +let reflection_conformance_corpus_json : String = "{\n \"version\": \"1.0.0\",\n \"schemaKinds\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ],\n \"restrictionKinds\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ],\n \"caseIds\": [\n \"canonical/s64-min\",\n \"canonical/s64-max\",\n \"canonical/u64-max\",\n \"canonical/duration-min\",\n \"canonical/duration-max\",\n \"canonical/quantity-wide-mantissa\",\n \"canonical/omitted-options\",\n \"canonical/binary-mime\",\n \"errors/s64-plus\",\n \"errors/s64-leading-zero\",\n \"errors/s64-negative-zero\",\n \"errors/s64-overflow\",\n \"errors/u64-negative\",\n \"errors/u64-overflow\",\n \"errors/wide-wrong-json-type\",\n \"errors/duration-legacy-string\",\n \"errors/quantity-negative-zero\",\n \"errors/quantity-extra-field\",\n \"errors/binary-noncanonical-base64\",\n \"json-schema/s64\",\n \"json-schema/s64-restrictions\",\n \"json-schema/u32-restrictions\",\n \"json-schema/f64-restrictions\",\n \"json-schema/text-restrictions\",\n \"json-schema/binary-restrictions\",\n \"json-schema/u64\",\n \"json-schema/duration-nanoseconds\",\n \"json-schema/quantity-mantissa\",\n \"json-schema/optional-required\",\n \"json-schema/unsupported-leaves\",\n \"schema/all-kinds\",\n \"schema/all-restrictions\",\n \"graph/references-validation-equivalence\",\n \"tool/canonical-input\",\n \"config/canonical-entry\",\n \"constraints/rejected-value\",\n \"compatibility/result\",\n \"compatibility/custom-error\"\n ],\n \"cases\": [\n {\n \"id\": \"canonical/s64-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"-9223372036854775808\",\n \"expected\": \"-9223372036854775808\"\n },\n {\n \"id\": \"canonical/s64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775807\",\n \"expected\": \"9223372036854775807\"\n },\n {\n \"id\": \"canonical/u64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551615\",\n \"expected\": \"18446744073709551615\"\n },\n {\n \"id\": \"canonical/duration-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"-9223372036854775808\"\n },\n \"expected\": {\n \"nanoseconds\": \"-9223372036854775808\"\n }\n },\n {\n \"id\": \"canonical/duration-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"9223372036854775807\"\n },\n \"expected\": {\n \"nanoseconds\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"canonical/quantity-wide-mantissa\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n }\n },\n {\n \"id\": \"canonical/omitted-options\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"optional-record\",\n \"input\": {},\n \"expected\": {\n \"direct\": null,\n \"referenced\": null\n }\n },\n {\n \"id\": \"canonical/binary-mime\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"binary\",\n \"input\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n },\n \"expected\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n }\n },\n {\n \"id\": \"errors/s64-plus\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"+1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-leading-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"01\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"-0\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775808\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-negative\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"-1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551616\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/wide-wrong-json-type\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": 1,\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/duration-legacy-string\",\n \"operation\": \"reject\",\n \"fixture\": \"duration\",\n \"input\": \"PT1S\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-0\",\n \"scale\": 0,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-extra-field\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"1\",\n \"scale\": 0,\n \"unit\": \"m\",\n \"extra\": true\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/binary-noncanonical-base64\",\n \"operation\": \"reject\",\n \"fixture\": \"binary\",\n \"inputs\": [\n {\n \"bytes\": \"+/8\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8=\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_9\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"A\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8\",\n \"mime_type\": \"application/octet-stream\"\n }\n ],\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"json-schema/s64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/s64-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9007199254740993\",\n \"x-golem-maximum\": \"9007199254740993\"\n }\n },\n {\n \"id\": \"json-schema/u64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"u64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"uint64\",\n \"pattern\": \"^(?:0|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"0\",\n \"x-golem-maximum\": \"18446744073709551615\"\n }\n },\n {\n \"id\": \"json-schema/u32-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-u32\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"integer\",\n \"minimum\": 2,\n \"maximum\": 10\n }\n },\n {\n \"id\": \"json-schema/f64-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-f64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"number\",\n \"minimum\": -1.5,\n \"maximum\": 2.5\n }\n },\n {\n \"id\": \"json-schema/text-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-text\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"object\",\n \"properties\": {\n \"text\": {\n \"type\": \"string\",\n \"minLength\": 2,\n \"maxLength\": 8,\n \"pattern\": \"^[a-z]+$\"\n },\n \"language\": {\n \"type\": \"string\",\n \"enum\": [\n \"en\",\n \"de\"\n ]\n }\n },\n \"required\": [\n \"text\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"id\": \"json-schema/binary-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-binary\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"object\",\n \"properties\": {\n \"bytes\": {\n \"type\": \"string\",\n \"contentEncoding\": \"base64url\",\n \"pattern\": \"^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$\",\n \"minLength\": 3,\n \"maxLength\": 6\n },\n \"mimeType\": {\n \"type\": \"string\",\n \"enum\": [\n \"image/png\",\n \"application/octet-stream\"\n ]\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"id\": \"json-schema/duration-nanoseconds\",\n \"operation\": \"json-schema\",\n \"fixture\": \"duration\",\n \"path\": \"/properties/nanoseconds\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/quantity-mantissa\",\n \"operation\": \"json-schema\",\n \"fixture\": \"quantity\",\n \"path\": \"/properties/mantissa\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/optional-required\",\n \"operation\": \"json-schema\",\n \"fixture\": \"optional-record\",\n \"path\": \"/required\",\n \"expected\": []\n },\n {\n \"id\": \"json-schema/unsupported-leaves\",\n \"operation\": \"semantic\",\n \"fixture\": \"unsupported-leaves\",\n \"expected\": {\n \"count\": 5,\n \"schema\": {\n \"not\": {}\n }\n }\n },\n {\n \"id\": \"schema/all-kinds\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-kinds\",\n \"expected\": {\n \"names\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ]\n }\n },\n {\n \"id\": \"schema/all-restrictions\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-restrictions\",\n \"expected\": {\n \"names\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ]\n }\n },\n {\n \"id\": \"graph/references-validation-equivalence\",\n \"operation\": \"semantic\",\n \"fixture\": \"graph\",\n \"expected\": {\n \"valid\": true,\n \"equivalent\": true\n }\n },\n {\n \"id\": \"tool/canonical-input\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"tool-input\",\n \"input\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ]\n },\n \"expected\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ],\n \"ignoreCase\": null\n }\n },\n {\n \"id\": \"config/canonical-entry\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"config-entry\",\n \"input\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n },\n \"expected\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"constraints/rejected-value\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-u32\",\n \"input\": 11,\n \"expected\": {\n \"kind\": \"constraint-violation\"\n }\n },\n {\n \"id\": \"compatibility/result\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"result\",\n \"input\": {\n \"ok\": \"ready\"\n },\n \"expected\": {\n \"ok\": \"ready\"\n }\n },\n {\n \"id\": \"compatibility/custom-error\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"custom-error\",\n \"input\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n },\n \"expected\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n }\n }\n ]\n}\n" diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt index 2c0c36f074..ea9cc32240 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt @@ -97,7 +97,40 @@ fn conformance_fixture(name : String) -> SchemaRef raise { ) "constrained-u32" => @model.schema_type( - U32(Some({ min: None, max: Some(@types.Unsigned(10UL)), unit: None, })), + U32( + Some({ + min: Some(@types.Unsigned(2UL)), + max: Some(@types.Unsigned(10UL)), + unit: None, + }), + ), + ) + "constrained-f64" => + @model.schema_type( + F64( + Some({ + min: Some(@types.FloatBits((-1.5).reinterpret_as_uint64())), + max: Some(@types.FloatBits(2.5.reinterpret_as_uint64())), + unit: None, + }), + ), + ) + "constrained-text" => + @model.schema_type( + Text({ + languages: Some(["en", "de"]), + min_length: Some(2U), + max_length: Some(8U), + regex: Some("^[a-z]+$"), + }), + ) + "constrained-binary" => + @model.schema_type( + Binary({ + mime_types: Some(["image/png", "application/octet-stream"]), + min_bytes: Some(2U), + max_bytes: Some(4U), + }), ) "result" => @model.schema_type( @@ -273,6 +306,28 @@ fn assert_conformance_semantic( } } +///| +fn conformance_reject_kind( + schema : SchemaRef, + input : Json, + id : String, +) -> String raise { + let packed = try schema.pack(schema.resolve(schema.root), input) catch { + _ => None + } noraise { + value => Some(value) + } + match packed { + None => "invalid-json" + Some(value) => + try schema.validate_value(value) catch { + _ => "constraint-violation" + } noraise { + _ => fail("conformance case was accepted: " + id) + } + } +} + ///| test "reflection conformance corpus executes the complete case-ID set" { let corpus = @json.parse(reflection_conformance_corpus_json) @@ -303,11 +358,14 @@ test "reflection conformance corpus executes the complete case-ID set" { None => [test_case.get("input").unwrap()] } for input in inputs { - try schema.pack_json(input) catch { - _ => () - } noraise { - _ => fail("conformance case was accepted: " + id) - } + assert_eq( + conformance_reject_kind(schema, input, id), + corpus_string( + corpus_object(test_case.get("expected").unwrap()) + .get("kind") + .unwrap(), + ), + ) } } "json-schema" => diff --git a/sdks/rust/golem-rust/src/agentic/reflection.rs b/sdks/rust/golem-rust/src/agentic/reflection.rs index d1ec6fd697..7bc401b0e7 100644 --- a/sdks/rust/golem-rust/src/agentic/reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/reflection.rs @@ -1678,12 +1678,13 @@ mod tests { ReflectedTransport, SchemaRef, TestReflectedTransport, validate_declared_output, }; use crate::bindings::golem::agent::common as wire_common; + use crate::schema::render::from_json_value; use crate::schema::schema_type::{NumericBound, NumericRestrictions}; - use crate::schema::validation::{is_equivalent_cross_graph, validate_graph}; + use crate::schema::validation::{is_equivalent_cross_graph, validate_graph, validate_value}; use crate::schema::{ BinaryRestrictions, MetadataEnvelope, NamedFieldType, PermissionCardSpec, QuantitySpec, - QuotaTokenSpec, ResultSpec, SchemaGraph, SchemaType, SchemaTypeDef, SchemaValue, TypeId, - VariantCaseType, VariantValuePayload, + QuotaTokenSpec, ResultSpec, SchemaGraph, SchemaType, SchemaTypeDef, SchemaValue, + TextRestrictions, TypeId, VariantCaseType, VariantValuePayload, }; use serde_json::{Value, json}; use std::collections::HashSet; @@ -1789,12 +1790,34 @@ mod tests { ]), "constrained-u32" => SchemaType::U32 { restrictions: Some(NumericRestrictions { - min: None, + min: Some(NumericBound::Unsigned(2)), max: Some(NumericBound::Unsigned(10)), unit: None, }), metadata: MetadataEnvelope::default(), }, + "constrained-f64" => SchemaType::F64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::float(-1.5).unwrap()), + max: Some(NumericBound::float(2.5).unwrap()), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + "constrained-text" => SchemaType::text(TextRestrictions { + languages: Some(vec!["en".to_string(), "de".to_string()]), + min_length: Some(2), + max_length: Some(8), + regex: Some("^[a-z]+$".to_string()), + }), + "constrained-binary" => SchemaType::binary(BinaryRestrictions { + mime_types: Some(vec![ + "image/png".to_string(), + "application/octet-stream".to_string(), + ]), + min_bytes: Some(2), + max_bytes: Some(4), + }), "result" => SchemaType::result(ResultSpec { ok: Some(Box::new(SchemaType::string())), err: Some(Box::new(SchemaType::u32())), @@ -1995,7 +2018,21 @@ mod tests { .cloned() .unwrap_or_else(|| vec![case["input"].clone()]); for input in inputs { - assert!(schema.pack_json(&input).is_err(), "{id} accepted {input}"); + let actual = match from_json_value(schema.graph(), schema.root(), &input) { + Err(_) => "invalid-json", + Ok(value) + if validate_value(schema.graph(), schema.root(), &value) + .is_err() => + { + "constraint-violation" + } + Ok(value) => panic!("{id} accepted {input} as {value:?}"), + }; + assert_eq!( + actual, + expected["kind"].as_str().expect("reject kind"), + "{id}" + ); } } "json-schema" => { diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala index a8b5645e24..2b3285575e 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala @@ -101,6 +101,10 @@ private[reflection] object ReflectionConformanceCorpus { "errors/binary-noncanonical-base64", "json-schema/s64", "json-schema/s64-restrictions", + "json-schema/u32-restrictions", + "json-schema/f64-restrictions", + "json-schema/text-restrictions", + "json-schema/binary-restrictions", "json-schema/u64", "json-schema/duration-nanoseconds", "json-schema/quantity-mantissa", @@ -365,6 +369,85 @@ private[reflection] object ReflectionConformanceCorpus { "x-golem-maximum": "18446744073709551615" } }, + { + "id": "json-schema/u32-restrictions", + "operation": "json-schema", + "fixture": "constrained-u32", + "path": "", + "expected": { + "type": "integer", + "minimum": 2, + "maximum": 10 + } + }, + { + "id": "json-schema/f64-restrictions", + "operation": "json-schema", + "fixture": "constrained-f64", + "path": "", + "expected": { + "type": "number", + "minimum": -1.5, + "maximum": 2.5 + } + }, + { + "id": "json-schema/text-restrictions", + "operation": "json-schema", + "fixture": "constrained-text", + "path": "", + "expected": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 2, + "maxLength": 8, + "pattern": "^[a-z]+$" + }, + "language": { + "type": "string", + "enum": [ + "en", + "de" + ] + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + }, + { + "id": "json-schema/binary-restrictions", + "operation": "json-schema", + "fixture": "constrained-binary", + "path": "", + "expected": { + "type": "object", + "properties": { + "bytes": { + "type": "string", + "contentEncoding": "base64url", + "pattern": "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + "minLength": 3, + "maxLength": 6 + }, + "mimeType": { + "type": "string", + "enum": [ + "image/png", + "application/octet-stream" + ] + } + }, + "required": [ + "bytes" + ], + "additionalProperties": false + } + }, { "id": "json-schema/duration-nanoseconds", "operation": "json-schema", diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala index 55fef169eb..515a144c40 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala @@ -85,7 +85,48 @@ object ReflectionConformanceSpec extends ZIOSpecDefault { ) ) case "constrained-u32" => - SchemaType(U32Type(Some(NumericRestrictions(max = Some(NumericBound.Unsigned(10)))))) + SchemaType( + U32Type( + Some( + NumericRestrictions( + min = Some(NumericBound.Unsigned(2)), + max = Some(NumericBound.Unsigned(10)) + ) + ) + ) + ) + case "constrained-f64" => + SchemaType( + F64Type( + Some( + NumericRestrictions( + min = Some(NumericBound.FloatBits(java.lang.Double.doubleToRawLongBits(-1.5))), + max = Some(NumericBound.FloatBits(java.lang.Double.doubleToRawLongBits(2.5))) + ) + ) + ) + ) + case "constrained-text" => + SchemaType( + TextType( + TextRestrictions( + languages = Some(List("en", "de")), + minLength = Some(2), + maxLength = Some(8), + regex = Some("^[a-z]+$") + ) + ) + ) + case "constrained-binary" => + SchemaType( + BinaryType( + BinaryRestrictions( + mimeTypes = Some(List("image/png", "application/octet-stream")), + minBytes = Some(2), + maxBytes = Some(4) + ) + ) + ) case "result" => SchemaType(ResultType(Some(SchemaType(StringType)), Some(SchemaType(U32Type())))) case "custom-error" => @@ -280,7 +321,15 @@ object ReflectionConformanceSpec extends ZIOSpecDefault { Predef.assert(schema.unpackJson(packed) == Right(testCase("expected")), id) case "reject" => val inputs = testCase.get("inputs").map(elements).getOrElse(List(testCase("input"))) - inputs.foreach(input => Predef.assert(fixture(name).validateJson(input).isLeft, s"$id accepted $input")) + inputs.foreach { input => + val schema = fixture(name) + val actual = schema.packJson(input) match { + case Left(_) => "invalid-json" + case Right(value) if schema.validateValue(value).isLeft => "constraint-violation" + case Right(value) => throw new AssertionError(s"$id accepted $input as $value") + } + Predef.assert(actual == string(fields(testCase("expected"))("kind")), id) + } case "json-schema" => assertSubset( atPointer(fixture(name).toJsonSchema(false), string(testCase("path"))), diff --git a/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts index fbb4759d8b..e56a205bbe 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts @@ -153,7 +153,42 @@ function fixture(name: string): SchemaRef { case 'config-entry': return schema(t.record([field('path', t.list(t.string())), field('value', t.s64())])); case 'constrained-u32': - return schema(t.u32({ max: { tag: 'unsigned', val: 10n } })); + return schema( + t.u32({ + min: { tag: 'unsigned', val: 2n }, + max: { tag: 'unsigned', val: 10n }, + }), + ); + case 'constrained-f64': + return schema( + t.f64({ + min: { tag: 'float-bits', val: 0xbff8000000000000n }, + max: { tag: 'float-bits', val: 0x4004000000000000n }, + }), + ); + case 'constrained-text': + return schema( + schemaType({ + tag: 'text', + restrictions: { + languages: ['en', 'de'], + minLength: 2, + maxLength: 8, + regex: '^[a-z]+$', + }, + }), + ); + case 'constrained-binary': + return schema( + schemaType({ + tag: 'binary', + restrictions: { + mimeTypes: ['image/png', 'application/octet-stream'], + minBytes: 2, + maxBytes: 4, + }, + }), + ); case 'result': return schema(t.result(t.string(), t.u32())); case 'custom-error': @@ -253,7 +288,21 @@ describe('reflection conformance corpus', () => { case 'reject': { const ref = fixture(testCase.fixture); for (const input of testCase.inputs ?? [testCase.input!]) { - expect(ref.validateJson(input).success, testCase.id).toBe(false); + let packed: ReturnType; + try { + packed = ref.packJson(input); + } catch { + expect('invalid-json', testCase.id).toBe( + (testCase.expected as { readonly kind: string }).kind, + ); + continue; + } + if (ref.validateValue(packed).success) { + throw new Error(`${testCase.id} accepted ${JSON.stringify(input)}`); + } + expect('constraint-violation', testCase.id).toBe( + (testCase.expected as { readonly kind: string }).kind, + ); } break; } diff --git a/test-data/reflection-conformance/v1.json b/test-data/reflection-conformance/v1.json index 3449b33547..c184339bf2 100644 --- a/test-data/reflection-conformance/v1.json +++ b/test-data/reflection-conformance/v1.json @@ -38,6 +38,10 @@ "errors/binary-noncanonical-base64", "json-schema/s64", "json-schema/s64-restrictions", + "json-schema/u32-restrictions", + "json-schema/f64-restrictions", + "json-schema/text-restrictions", + "json-schema/binary-restrictions", "json-schema/u64", "json-schema/duration-nanoseconds", "json-schema/quantity-mantissa", @@ -231,6 +235,75 @@ "x-golem-maximum": "18446744073709551615" } }, + { + "id": "json-schema/u32-restrictions", + "operation": "json-schema", + "fixture": "constrained-u32", + "path": "", + "expected": { + "type": "integer", + "minimum": 2, + "maximum": 10 + } + }, + { + "id": "json-schema/f64-restrictions", + "operation": "json-schema", + "fixture": "constrained-f64", + "path": "", + "expected": { + "type": "number", + "minimum": -1.5, + "maximum": 2.5 + } + }, + { + "id": "json-schema/text-restrictions", + "operation": "json-schema", + "fixture": "constrained-text", + "path": "", + "expected": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 2, + "maxLength": 8, + "pattern": "^[a-z]+$" + }, + "language": { + "type": "string", + "enum": ["en", "de"] + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + { + "id": "json-schema/binary-restrictions", + "operation": "json-schema", + "fixture": "constrained-binary", + "path": "", + "expected": { + "type": "object", + "properties": { + "bytes": { + "type": "string", + "contentEncoding": "base64url", + "pattern": "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + "minLength": 3, + "maxLength": 6 + }, + "mimeType": { + "type": "string", + "enum": ["image/png", "application/octet-stream"] + } + }, + "required": ["bytes"], + "additionalProperties": false + } + }, { "id": "json-schema/duration-nanoseconds", "operation": "json-schema", From 1a53e63bbabed1df54b23f7f87083a41c4729743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 24 Sep 2026 16:53:39 +0200 Subject: [PATCH 29/37] ci: restore cached CLI test binaries --- .github/workflows/ci.yaml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6cb799c03e..eb15d88bca 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -278,14 +278,11 @@ jobs: cp target/dev-release-ci/golem target/dev-release-ci/golem-cli tmp/golem-cli-test-binaries/ find tmp/golem-cli-test-binaries -type f | sort timeout-minutes: 2 - - name: Upload CLI test binaries - uses: actions/upload-artifact@v6 + - name: Save CLI test binaries to cache + uses: actions/cache/save@v5 with: path: tmp/golem-cli-test-binaries - name: golem-cli-test-binaries - if-no-files-found: error - overwrite: true - retention-days: 1 + key: golem-cli-test-binaries-${{ github.run_id }} it: env: @@ -491,11 +488,12 @@ jobs: run-id: ${{ github.run_id }} copy-to-target: 'true' - - name: Download CLI test binaries - uses: actions/download-artifact@v8 + - name: Restore CLI test binaries + uses: actions/cache/restore@v5 with: path: tmp/golem-cli-test-binaries - name: golem-cli-test-binaries + key: golem-cli-test-binaries-${{ github.run_id }} + fail-on-cache-miss: true - name: Install CLI test binaries run: | mkdir -pv target/dev-release-ci @@ -1077,11 +1075,12 @@ jobs: rust-targets: 'wasm32-wasip1 wasm32-wasip2' install-cargo-make: 'false' install-cargo-binstall: 'true' - - name: Download CLI test binaries - uses: actions/download-artifact@v8 + - name: Restore CLI test binaries + uses: actions/cache/restore@v5 with: path: tmp/golem-cli-test-binaries - name: golem-cli-test-binaries + key: golem-cli-test-binaries-${{ github.run_id }} + fail-on-cache-miss: true - name: Install golem-cli run: chmod +x tmp/golem-cli-test-binaries/golem-cli From e27b2c1e73de7846476e72e86d6827f6be361294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 25 Sep 2026 12:41:02 +0200 Subject: [PATCH 30/37] fix: preserve canonical config encoding across bridges --- .../bridge_gen/moonbit/runtime/protocol.mbt | 5 +- .../moonbit/runtime/public_value_codec.mbt | 151 +++++++++++--- cli/golem-cli/src/bridge_gen/rust/mod.rs | 11 +- .../golem/bridge/runtime/BridgeProtocol.scala | 2 +- .../bridge/runtime/PublicValueCodec.scala | 73 ++++--- .../src/bridge_gen/typescript/mod.rs | 186 +++++++++++++++++- cli/golem-cli/tests/bridge_gen/moonbit.rs | 45 ++++- cli/golem-cli/tests/bridge_gen/rust.rs | 12 +- cli/golem-cli/tests/bridge_gen/scala.rs | 37 +++- cli/golem-cli/tests/bridge_gen/typescript.rs | 68 +++++-- golem-common/src/schema/render/mod.rs | 1 + .../src/service/worker/service.rs | 147 ++++++++++++-- 12 files changed, 637 insertions(+), 101 deletions(-) diff --git a/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt b/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt index 6d47670595..b9cd20332c 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt +++ b/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt @@ -75,7 +75,10 @@ pub(all) struct ResolvedAgent { ///| fn encode_config_entry(entry : AgentConfigEntry) -> Json raise { let path : Array[Json] = entry.path.map(Json::string) - obj([("path", Json::array(path)), ("value", entry.codec.encode(entry.value))]) + obj([ + ("path", Json::array(path)), + ("value", entry.codec.encode_canonical(entry.value)), + ]) } ///| diff --git a/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt b/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt index b75a0c9843..a0ae414cc3 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt +++ b/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt @@ -49,7 +49,28 @@ pub fn PublicValueCodec::encode( value : SchemaValue, ) -> Json raise { let charge = Ref(0) - self.encode_at(pvc_field(pvc_object(self.graph), "root"), value, 0, charge) + self.encode_at( + pvc_field(pvc_object(self.graph), "root"), + value, + 0, + charge, + false, + ) +} + +///| +fn PublicValueCodec::encode_canonical( + self : PublicValueCodec, + value : SchemaValue, +) -> Json raise { + let charge = Ref(0) + self.encode_at( + pvc_field(pvc_object(self.graph), "root"), + value, + 0, + charge, + true, + ) } ///| @@ -1488,6 +1509,7 @@ fn PublicValueCodec::encode_at( value : SchemaValue, depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { if depth >= 64 { pvc_fail("nesting exceeds 64 levels") @@ -1570,11 +1592,17 @@ fn PublicValueCodec::encode_at( Json::string(v.to_string()) } ("f32", F32Value(v)) => { + if canonical && (v.is_nan() || v.is_inf()) { + pvc_fail("canonical configuration floats must be finite") + } pvc_numeric_restrictions(schema, "float", None, None, Some(v.to_double())) pvc_charge(charge, 4) pvc_encode_float(v.to_double()) } ("f64", F64Value(v)) => { + if canonical && (v.is_nan() || v.is_inf()) { + pvc_fail("canonical configuration floats must be finite") + } pvc_numeric_restrictions(schema, "float", None, None, Some(v)) pvc_charge(charge, 8) pvc_encode_float(v) @@ -1588,9 +1616,10 @@ fn PublicValueCodec::encode_at( pvc_charge(charge, @utf8.encode(v).length()) Json::string(v) } - ("record", RecordValue(v)) => self.encode_record(schema, v, depth, charge) + ("record", RecordValue(v)) => + self.encode_record(schema, v, depth, charge, canonical) ("variant", VariantValue(i, v)) => - self.encode_variant(schema, i, v, depth, charge) + self.encode_variant(schema, i, v, depth, charge, canonical) ("enum", EnumValue(i)) => { let cases = pvc_array(pvc_field(schema, "cases")) if i < 0 || i >= cases.length() { @@ -1622,26 +1651,46 @@ fn PublicValueCodec::encode_at( v, depth, charge, + canonical, ) ("list", ListValue(v)) => - self.encode_homogeneous(pvc_field(schema, "element"), v, depth, charge) + self.encode_homogeneous( + pvc_field(schema, "element"), + v, + depth, + charge, + canonical, + ) ("fixed-list", FixedListValue(v)) => { if v.length() != pvc_u32(pvc_field(schema, "length")).reinterpret_as_int() { pvc_fail("fixed-list length mismatch") } - self.encode_homogeneous(pvc_field(schema, "element"), v, depth, charge) + self.encode_homogeneous( + pvc_field(schema, "element"), + v, + depth, + charge, + canonical, + ) } ("map", MapValue(v)) => { pvc_collection(charge, v.length()) Json::array( v.map(e => { Json::array([ - self.encode_at(pvc_field(schema, "key"), e.key, depth + 1, charge), + self.encode_at( + pvc_field(schema, "key"), + e.key, + depth + 1, + charge, + canonical, + ), self.encode_at( pvc_field(schema, "value"), e.value, depth + 1, charge, + canonical, ), ]) }), @@ -1649,22 +1698,38 @@ fn PublicValueCodec::encode_at( } ("option", OptionValue(v)) => match v { + None if canonical => Json::null() None => { pvc_charge(charge, 4) pvc_obj([("$option", Json::string("none"))]) } + Some(x) if canonical => + self.encode_at( + pvc_field(schema, "inner"), + x, + depth + 1, + charge, + canonical, + ) Some(x) => { pvc_charge(charge, 4) pvc_obj([ ("$option", Json::string("some")), ( "value", - self.encode_at(pvc_field(schema, "inner"), x, depth + 1, charge), + self.encode_at( + pvc_field(schema, "inner"), + x, + depth + 1, + charge, + canonical, + ), ), ]) } } - ("result", ResultValue(v)) => self.encode_result(schema, v, depth, charge) + ("result", ResultValue(v)) => + self.encode_result(schema, v, depth, charge, canonical) ("text", TextValue(text, language)) => { match language { Some(x) => @@ -1757,13 +1822,18 @@ fn PublicValueCodec::encode_at( pvc_fail("unknown union branch") } pvc_discriminator(self, ty, body, branches, tag) - pvc_charge(charge, pvc_utf8_len(tag)) - pvc_obj([ - ("$union", Json::string(tag)), - ("value", self.encode_at(ty, body, depth + 1, charge)), - ]) + let encoded = self.encode_at(ty, body, depth + 1, charge, canonical) + if canonical { + encoded + } else { + pvc_charge(charge, pvc_utf8_len(tag)) + pvc_obj([("$union", Json::string(tag)), ("value", encoded)]) + } } - ("stream", StreamReferenceValue(provisional, token)) => + ("stream", StreamReferenceValue(provisional, token)) => { + if canonical { + pvc_fail("streams are unsupported in canonical configuration values") + } match (provisional, token) { (Some(v), None) => { if !pvc_uuid_v4(v) { @@ -1790,6 +1860,7 @@ fn PublicValueCodec::encode_at( Json::null() } } + } ("secret" | "quota-token" | "permission-card" | "future", _) => { pvc_fail("unsupported value kind '\{kind}'") Json::null() @@ -1808,6 +1879,7 @@ fn PublicValueCodec::encode_record( values : Array[SchemaValue], depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { let fields = pvc_array(pvc_field(schema, "fields")) if fields.length() != values.length() { @@ -1824,6 +1896,7 @@ fn PublicValueCodec::encode_record( values[i], depth + 1, charge, + canonical, ) } Json::object(out) @@ -1837,6 +1910,7 @@ fn PublicValueCodec::encode_variant( payload : SchemaValue?, depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { let cases = pvc_array(pvc_field(schema, "cases")) if index < 0 || index >= cases.length() { @@ -1849,13 +1923,22 @@ fn PublicValueCodec::encode_variant( if (ty is Some(_)) != (payload is Some(_)) { pvc_fail("variant payload mismatch") } - let fields = [("$case", Json::string(name))] match (ty, payload) { - (Some(t), Some(v)) => - fields.push(("value", self.encode_at(t, v, depth + 1, charge))) - _ => () + (Some(t), Some(v)) => { + let encoded = self.encode_at(t, v, depth + 1, charge, canonical) + if canonical { + pvc_obj([(name, encoded)]) + } else { + pvc_obj([("$case", Json::string(name)), ("value", encoded)]) + } + } + (None, None) if canonical => Json::string(name) + (None, None) => pvc_obj([("$case", Json::string(name))]) + _ => { + pvc_fail("variant payload mismatch") + Json::null() + } } - pvc_obj(fields) } ///| @@ -1865,13 +1948,16 @@ fn PublicValueCodec::encode_sequence( values : Array[SchemaValue], depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { if types.length() != values.length() { pvc_fail("tuple arity mismatch") } pvc_collection(charge, values.length()) Json::array( - values.mapi((i, v) => self.encode_at(types[i], v, depth + 1, charge)), + values.mapi((i, v) => + self.encode_at(types[i], v, depth + 1, charge, canonical) + ), ) } @@ -1882,9 +1968,12 @@ fn PublicValueCodec::encode_homogeneous( values : Array[SchemaValue], depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { pvc_collection(charge, values.length()) - Json::array(values.map(v => self.encode_at(ty, v, depth + 1, charge))) + Json::array( + values.map(v => self.encode_at(ty, v, depth + 1, charge, canonical)), + ) } ///| @@ -1894,6 +1983,7 @@ fn PublicValueCodec::encode_result( result : SchemaResult, depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { let spec = pvc_object(pvc_field(schema, "spec")) let (tag, value) = match result { @@ -1904,12 +1994,21 @@ fn PublicValueCodec::encode_result( if (ty is Some(_)) != (value is Some(_)) { pvc_fail("result payload mismatch") } - let fields = [("$result", Json::string(tag))] pvc_charge(charge, pvc_utf8_len(tag)) match (ty, value) { - (Some(t), Some(v)) => - fields.push(("value", self.encode_at(t, v, depth + 1, charge))) - _ => () + (Some(t), Some(v)) => { + let encoded = self.encode_at(t, v, depth + 1, charge, canonical) + if canonical { + pvc_obj([(tag, encoded)]) + } else { + pvc_obj([("$result", Json::string(tag)), ("value", encoded)]) + } + } + (None, None) if canonical => pvc_obj([(tag, Json::null())]) + (None, None) => pvc_obj([("$result", Json::string(tag))]) + _ => { + pvc_fail("result payload mismatch") + Json::null() + } } - pvc_obj(fields) } diff --git a/cli/golem-cli/src/bridge_gen/rust/mod.rs b/cli/golem-cli/src/bridge_gen/rust/mod.rs index 4cefe3138e..7c6839fa97 100644 --- a/cli/golem-cli/src/bridge_gen/rust/mod.rs +++ b/cli/golem-cli/src/bridge_gen/rust/mod.rs @@ -443,17 +443,22 @@ impl RustBridgeGenerator { let __config_graph: crate::__golem_bridge_runtime::schema::SchemaGraph = serde_json::from_str(#config_graph_json) .map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to load config schema: {__e}") })?; - let __config_json = golem_client::invocation_session::encode_generated_streamless_value( + let __public_config_json = golem_client::invocation_session::encode_generated_streamless_value( &__config_graph, &__config_value, ).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to encode public config value: {__e}") })?; + let __canonical_config_json = golem_common::schema::render::to_json_value( + &__config_graph, + &__config_graph.root, + &__config_value, + ).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to encode canonical config value: {__e}") })?; session_config.push(golem_common::model::invocation_session_public::PublicConfigEntry { path: vec![#(#path_segments),*], - value: __config_json.clone(), + value: __public_config_json, }); agent_config.push(golem_client::model::AgentConfigEntryDto { path: vec![#(#path_segments),*], - value: __config_json.into(), + value: __canonical_config_json.into(), }); } }); diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala index 629ab9c5c0..68ddd5f57a 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala @@ -118,7 +118,7 @@ object BridgeProtocol { private def encodeConfigEntry(entry: AgentConfigEntry): Json = Json.obj( "path" -> Json.arr(entry.path.map(Json.string).toVector), - "value" -> entry.codec.encode(entry.value) + "value" -> entry.codec.encodeCanonical(entry.value) ) def decodeCreateAgentResponse(json: Json): Either[String, CreateAgentResponse] = diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala index 68bf661349..a39b4422df 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala @@ -35,14 +35,17 @@ object PublicValueCodec { private val defs: Map[String, Schema] ) { def encode(value: SchemaValue): json.Json = - encodeAt(root, value, 0, new Budget) + encodeAt(root, value, 0, new Budget, canonical = false) + + private[runtime] def encodeCanonical(value: SchemaValue): json.Json = + encodeAt(root, value, 0, new Budget, canonical = true) def decode(value: json.Json): SchemaValue = { rejectDuplicates(value, "$value") decodeAt(root, value, 0, new Budget) } - private def encodeAt(schema0: Schema, value: SchemaValue, depth: Int, budget: Budget): json.Json = { + private def encodeAt(schema0: Schema, value: SchemaValue, depth: Int, budget: Budget, canonical: Boolean): json.Json = { checkDepth(depth) budget.add(1) val schema = resolve(schema0) @@ -56,8 +59,8 @@ object PublicValueCodec { case ("u16", U16Value(v)) => integer(v.toLong, 0, 65535, 2, schema, budget) case ("u32", U32Value(v)) => integer(v, 0, 4294967295L, 4, schema, budget) case ("u64", U64Value(v)) => checkedDecimal(BigInt(v) & MaxU64, signed = false, schema, budget) - case ("f32", F32Value(v)) => encodeFloat(v.toDouble, true, 4, schema, budget) - case ("f64", F64Value(v)) => encodeFloat(v, false, 8, schema, budget) + case ("f32", F32Value(v)) => encodeFloat(v.toDouble, true, 4, schema, budget, canonical) + case ("f64", F64Value(v)) => encodeFloat(v, false, 8, schema, budget, canonical) case ("char", CharValue(v)) => if (!Character.isValidCodePoint(v) || v >= 0xd800 && v <= 0xdfff) fail("invalid char value") val s = new String(Character.toChars(v)); budget.string(s); json.Json.string(s) @@ -71,7 +74,7 @@ object PublicValueCodec { collection(values.length, budget) json.Json.obj(fields.zip(values).map { case ((name, ty), v) => budget.string(name) - name -> encodeAt(ty, v, depth + 1, budget) + name -> encodeAt(ty, v, depth + 1, budget, canonical) }) case ("variant", VariantValue(index, payload)) => val cases = schemaArray(schema, "cases") @@ -79,11 +82,17 @@ object PublicValueCodec { val obj = objectFields(cases(index), "schema variant case") val name = stringField(obj, "name") val payloadSchema = optionalSchemaField(obj, "payload") - val base = Vector[(String, json.Json)]("$case" -> json.Json.string(name)) budget.string(name) (payloadSchema, payload) match { - case (None, None) => json.Json.obj(base) - case (Some(ty), Some(v)) => json.Json.obj(base :+ ("value" -> encodeAt(ty, v, depth + 1, budget))) + case (None, None) if canonical => json.Json.string(name) + case (None, None) => json.Json.obj("$case" -> json.Json.string(name)) + case (Some(ty), Some(v)) if canonical => + json.Json.obj(name -> encodeAt(ty, v, depth + 1, budget, canonical)) + case (Some(ty), Some(v)) => + json.Json.obj( + "$case" -> json.Json.string(name), + "value" -> encodeAt(ty, v, depth + 1, budget, canonical) + ) case _ => fail("variant payload presence does not match schema") } case ("enum", EnumValue(index)) => @@ -96,23 +105,25 @@ object PublicValueCodec { val selected = flags.zip(bits).collect { case (name, true) => name } collection(selected.length, budget) json.Json.arr(selected.map { name => budget.string(name); json.Json.string(name) }.toVector) - case ("tuple", TupleValue(values)) => encodeSequence(schemaArray(schema, "elements").map(parseSchema), values, depth, budget, "tuple") - case ("list", ListValue(values)) => encodeRepeated(schemaField(schemaValue(schema), "element"), values, depth, budget, None) + case ("tuple", TupleValue(values)) => encodeSequence(schemaArray(schema, "elements").map(parseSchema), values, depth, budget, "tuple", canonical) + case ("list", ListValue(values)) => encodeRepeated(schemaField(schemaValue(schema), "element"), values, depth, budget, None, canonical) case ("fixed-list", FixedListValue(values)) => val length = schemaU32(schema, "length") - encodeRepeated(schemaField(schemaValue(schema), "element"), values, depth, budget, Some(length)) + encodeRepeated(schemaField(schemaValue(schema), "element"), values, depth, budget, Some(length), canonical) case ("map", MapValue(entries)) => collection(entries.length, budget) val obj = schemaValue(schema) val key = schemaField(obj, "key"); val valueType = schemaField(obj, "value") - json.Json.arr(entries.map(e => json.Json.arr(Vector(encodeAt(key, e.key, depth + 1, budget), encodeAt(valueType, e.value, depth + 1, budget)))).toVector) + json.Json.arr(entries.map(e => json.Json.arr(Vector(encodeAt(key, e.key, depth + 1, budget, canonical), encodeAt(valueType, e.value, depth + 1, budget, canonical)))).toVector) case ("option", OptionValue(inner)) => val ty = schemaField(schemaValue(schema), "inner") inner match { - case None => budget.add(4); json.Json.obj("$option" -> json.Json.string("none")) - case Some(v) => budget.add(4); json.Json.obj("$option" -> json.Json.string("some"), "value" -> encodeAt(ty, v, depth + 1, budget)) + case None if canonical => json.Json.`null` + case None => budget.add(4); json.Json.obj("$option" -> json.Json.string("none")) + case Some(v) if canonical => encodeAt(ty, v, depth + 1, budget, canonical) + case Some(v) => budget.add(4); json.Json.obj("$option" -> json.Json.string("some"), "value" -> encodeAt(ty, v, depth + 1, budget, canonical)) } - case ("result", ResultValue(result)) => encodeResult(schema, result, depth, budget) + case ("result", ResultValue(result)) => encodeResult(schema, result, depth, budget, canonical) case ("text", TextValue(text, language)) => validateText(schema, text, language); budget.string(text); language.foreach(budget.string) json.Json.obj(Vector("text" -> json.Json.string(text)) ++ language.map(v => "language" -> json.Json.string(v))) @@ -133,11 +144,12 @@ object PublicValueCodec { ) case ("union", UnionValue(tag, body)) => val branch = unionBranch(schema, tag) - val encoded = encodeAt(schemaField(branch, "body"), body, depth + 1, budget) + val encoded = encodeAt(schemaField(branch, "body"), body, depth + 1, budget, canonical) if (!matchesDiscriminator(branch, encoded)) fail("union body does not satisfy discriminator") - budget.string(tag) - json.Json.obj("$union" -> json.Json.string(tag), "value" -> encoded) + if (canonical) encoded + else { budget.string(tag); json.Json.obj("$union" -> json.Json.string(tag), "value" -> encoded) } case ("stream", StreamReferenceValue(provisional, token, _)) => + if (canonical) fail("streams are unsupported in canonical configuration values") val field = (provisional, token) match { case (Some(v), None) => validateUuidV4(v); budget.add(16); "provisionalRef" -> json.Json.string(v) case (None, Some(v)) => if (v.isEmpty || utf8Length(v) > MaxStreamToken) fail("invalid stream token length"); budget.string(v); "streamToken" -> json.Json.string(v) @@ -266,9 +278,9 @@ object PublicValueCodec { loop(schema, Set.empty) } - private def encodeSequence(types: Vector[Schema], values: List[SchemaValue], depth: Int, budget: Budget, what: String): json.Json = { + private def encodeSequence(types: Vector[Schema], values: List[SchemaValue], depth: Int, budget: Budget, what: String, canonical: Boolean): json.Json = { if (types.length != values.length) fail(s"$what arity does not match schema") - collection(values.length, budget); json.Json.arr(types.zip(values).map { case (t, v) => encodeAt(t, v, depth + 1, budget) }) + collection(values.length, budget); json.Json.arr(types.zip(values).map { case (t, v) => encodeAt(t, v, depth + 1, budget, canonical) }) } private def decodeSequence(types: Vector[Schema], input: json.Json, depth: Int, budget: Budget, what: String): Vector[SchemaValue] = { @@ -276,9 +288,9 @@ object PublicValueCodec { collection(values.length, budget); types.zip(values).map { case (t, v) => decodeAt(t, v, depth + 1, budget) } } - private def encodeRepeated(ty: Schema, values: List[SchemaValue], depth: Int, budget: Budget, fixed: Option[Int]): json.Json = { + private def encodeRepeated(ty: Schema, values: List[SchemaValue], depth: Int, budget: Budget, fixed: Option[Int], canonical: Boolean): json.Json = { fixed.foreach(n => if (values.length != n) fail("fixed-list length does not match schema")) - collection(values.length, budget); json.Json.arr(values.map(v => encodeAt(ty, v, depth + 1, budget)).toVector) + collection(values.length, budget); json.Json.arr(values.map(v => encodeAt(ty, v, depth + 1, budget, canonical)).toVector) } private def decodeRepeated(ty: Schema, input: json.Json, depth: Int, budget: Budget, fixed: Option[Int]): Vector[SchemaValue] = { @@ -286,17 +298,23 @@ object PublicValueCodec { collection(values.length, budget); values.map(v => decodeAt(ty, v, depth + 1, budget)) } - private def encodeResult(schema: Schema, result: SchemaResult, depth: Int, budget: Budget): json.Json = { + private def encodeResult(schema: Schema, result: SchemaResult, depth: Int, budget: Budget, canonical: Boolean): json.Json = { val spec = objectField(schemaValue(schema), "spec") val (tag, payload, ty) = result match { case SchemaResult.Ok(v) => ("ok", v, optionalSchemaField(spec, "ok")) case SchemaResult.Err(v) => ("err", v, optionalSchemaField(spec, "err")) } budget.string(tag) - val base = Vector[(String, json.Json)]("$result" -> json.Json.string(tag)) (payload, ty) match { - case (None, None) => json.Json.obj(base) - case (Some(v), Some(t)) => json.Json.obj(base :+ ("value" -> encodeAt(t, v, depth + 1, budget))) + case (None, None) if canonical => json.Json.obj(tag -> json.Json.`null`) + case (None, None) => json.Json.obj("$result" -> json.Json.string(tag)) + case (Some(v), Some(t)) if canonical => + json.Json.obj(tag -> encodeAt(t, v, depth + 1, budget, canonical)) + case (Some(v), Some(t)) => + json.Json.obj( + "$result" -> json.Json.string(tag), + "value" -> encodeAt(t, v, depth + 1, budget, canonical) + ) case _ => fail("result payload presence does not match schema") } } @@ -347,8 +365,9 @@ object PublicValueCodec { n } - private def encodeFloat(value: Double, isF32: Boolean, width: Int, schema: Schema, budget: Budget): json.Json = { + private def encodeFloat(value: Double, isF32: Boolean, width: Int, schema: Schema, budget: Budget, canonical: Boolean): json.Json = { budget.add(width) + if (canonical && !value.isFinite) fail("canonical configuration floats must be finite") if (value.isFinite) validateNumeric(schema, BigDecimal(value)) else if (hasNumericBounds(schema)) fail("exceptional float does not satisfy numeric restrictions") if (value.isNaN) floatTag("nan") diff --git a/cli/golem-cli/src/bridge_gen/typescript/mod.rs b/cli/golem-cli/src/bridge_gen/typescript/mod.rs index 0e059b0ef3..7d7b3d53d2 100644 --- a/cli/golem-cli/src/bridge_gen/typescript/mod.rs +++ b/cli/golem-cli/src/bridge_gen/typescript/mod.rs @@ -1115,6 +1115,12 @@ impl TypeScriptBridgeGenerator { /// Generates a type definition and an encode/decode function pair for custom types used /// by the agent. pub(crate) fn generate_ts_type_definitions(&self, writer: &mut TsWriter) -> anyhow::Result<()> { + let needs_config_encoders = self.mode == TypeScriptBridgeMode::ExternalRest + && self + .agent_type + .config + .iter() + .any(|config| config.source == AgentConfigSource::Local); if self.has_external_streams() { for (typ, name) in self.type_naming.types() { writer.write_line(format!( @@ -1132,12 +1138,31 @@ impl TypeScriptBridgeGenerator { self.generate_ts_schema_type_encode(writer, name, typ)?; self.generate_ts_schema_type_decode(writer, name, typ)?; } - if self.has_external_streams() { + if self.has_external_streams() || needs_config_encoders { + let public_type = if self.has_external_streams() { + self.streaming_type_name(name).to_string() + } else { + name.to_string() + }; writer.write_line(format!( "function encodePublic{name}(value: {}, stream: any): any {{ return {}; }}", - self.streaming_type_name(name), + public_type, self.encode_public_value_body("value", typ, "stream")? )); + } + if needs_config_encoders { + let canonical_type = if self.has_external_streams() { + self.streaming_type_name(name).to_string() + } else { + name.to_string() + }; + writer.write_line(format!( + "function encodeCanonical{name}(value: {}): any {{ return {}; }}", + canonical_type, + self.encode_canonical_value_body("value", typ)? + )); + } + if self.has_external_streams() { writer.write_line(format!( "function decodePublic{name}(value: any, stream: any): {} {{ return {}; }}", self.streaming_type_name(name), @@ -1651,10 +1676,14 @@ impl TypeScriptBridgeGenerator { )?; let public_value = self.validate_public_value(&public_value, &config.value_type, "none"); + let canonical_value = self.encode_canonical_value(param_name, &config.value_type)?; writer.write_line(format!("if ({param_name} !== undefined) {{")); writer.indent(); + if !self.has_external_streams() { + writer.write_line(format!("void ({public_value});")); + } writer.write_line(format!( - "{}.push({{ path: {path}, value: {public_value} }});", + "{}.push({{ path: {path}, value: {canonical_value} }});", names.agent_config )); if self.has_external_streams() { @@ -2100,6 +2129,157 @@ impl TypeScriptBridgeGenerator { }) } + fn encode_canonical_value(&self, value: &str, typ: &SchemaType) -> anyhow::Result { + if let Some(name) = self.type_naming.type_name_for_type(typ) { + return Ok(format!("encodeCanonical{name}({value})")); + } + self.encode_canonical_value_body(value, typ) + } + + fn encode_canonical_value_body(&self, value: &str, typ: &SchemaType) -> anyhow::Result { + Ok(match typ { + SchemaType::S64 { .. } | SchemaType::U64 { .. } => format!("({value}).toString()"), + SchemaType::Bool { .. } + | SchemaType::S8 { .. } + | SchemaType::S16 { .. } + | SchemaType::S32 { .. } + | SchemaType::U8 { .. } + | SchemaType::U16 { .. } + | SchemaType::U32 { .. } + | SchemaType::Char { .. } + | SchemaType::String { .. } + | SchemaType::Path { .. } + | SchemaType::Url { .. } + | SchemaType::Datetime { .. } => value.to_string(), + SchemaType::F32 { .. } | SchemaType::F64 { .. } => format!( + "((v: number) => {{ if (!Number.isFinite(v)) throw new Error('configuration floats must be finite'); return v; }})({value})" + ), + SchemaType::Duration { .. } => { + format!("({{ nanoseconds: ({value}).toString() }})") + } + SchemaType::Quantity { .. } => format!( + "({{ mantissa: ({value}).mantissa.toString(), scale: ({value}).scale, unit: ({value}).unit }})" + ), + SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => format!( + "Array.from({value} as Iterable).map((item: any) => {})", + self.encode_canonical_value("item", element)? + ), + SchemaType::Tuple { elements, .. } => format!( + "[{}]", + elements + .iter() + .enumerate() + .map(|(index, element)| self + .encode_canonical_value(&format!("{value}[{index}]"), element)) + .collect::>>()? + .join(", ") + ), + SchemaType::Record { fields, .. } => format!( + "({{{}}})", + fields + .iter() + .zip(self.member_names(fields.iter().map(|field| field.name.as_str()))) + .map(|(field, member)| Ok(format!( + "{}: {}", + serde_json::to_string(&field.name)?, + self.encode_canonical_value(&format!("{value}.{member}"), &field.body)? + ))) + .collect::>>()? + .join(", ") + ), + SchemaType::Option { inner, .. } => format!( + "({value} === undefined ? null : {})", + self.encode_canonical_value(value, inner)? + ), + SchemaType::Binary { .. } => format!( + "({{ bytes: Buffer.from(({value}).bytes).toString('base64url'), ...(({value}).mimeType !== undefined ? {{ mimeType: ({value}).mimeType }} : {{}}) }})" + ), + SchemaType::Text { .. } => value.to_string(), + SchemaType::Map { + key, value: item, .. + } => format!( + "Array.from(({value} as Map).entries()).map(([key,val]) => [{}, {}])", + self.encode_canonical_value("key", key)?, + self.encode_canonical_value("val", item)? + ), + SchemaType::Enum { .. } => value.to_string(), + SchemaType::Flags { flags, .. } => { + let names = self.member_names(flags.iter().map(String::as_str)); + format!( + "[{}].filter((entry: [string, string]) => ({value} as any)[entry[1]]).map((entry: [string, string]) => entry[0])", + flags + .iter() + .zip(names) + .map(|(wire, member)| Ok(format!( + "[{}, {}]", + serde_json::to_string(wire)?, + serde_json::to_string(&member)? + ))) + .collect::>>()? + .join(",") + ) + } + SchemaType::Variant { cases, .. } => format!( + "((v:any) => {{ {} throw new Error('unknown variant'); }})({value})", + cases + .iter() + .map(|case| Ok(match &case.payload { + Some(payload) => format!( + "if(v.tag === {:?}) return {{ {:?}: {} }};", + case.name, + case.name, + self.encode_canonical_value("v.val", payload)? + ), + None => format!("if(v.tag === {:?}) return {:?};", case.name, case.name), + })) + .collect::>>()? + .join(" ") + ), + SchemaType::Result { spec, .. } => { + let ok = spec + .ok + .as_deref() + .map(|payload| self.encode_canonical_value("v.ok", payload)) + .transpose()? + .unwrap_or_else(|| "null".to_string()); + let err = spec + .err + .as_deref() + .map(|payload| self.encode_canonical_value("v.err", payload)) + .transpose()? + .unwrap_or_else(|| "null".to_string()); + format!("((v:any) => 'ok' in v ? {{ ok: {ok} }} : {{ err: {err} }})({value})") + } + SchemaType::Union { spec, .. } => format!( + "((v:any) => {{ {} throw new Error('unknown union'); }})({value})", + spec.branches + .iter() + .map(|branch| Ok(format!( + "if(v.tag === {:?}) return {};", + branch.tag, + self.encode_canonical_value("v.val", &branch.body)? + ))) + .collect::>>()? + .join(" ") + ), + SchemaType::Ref { id, .. } => { + let definition = self + .type_naming + .graph() + .lookup(id) + .ok_or_else(|| anyhow!("missing canonical schema ref {id}"))?; + self.encode_canonical_value(value, &definition.body)? + } + SchemaType::Secret { .. } + | SchemaType::QuotaToken { .. } + | SchemaType::PermissionCard { .. } + | SchemaType::Future { .. } + | SchemaType::Stream { .. } => format!( + "((_: any) => {{ throw new Error('unsupported configuration value'); }})({value})" + ), + }) + } + fn decode_public_value(&self, value: &str, typ: &SchemaType) -> anyhow::Result { if let Some(name) = self.type_naming.type_name_for_type(typ) { return Ok(format!("decodePublic{name}({value}, stream)")); diff --git a/cli/golem-cli/tests/bridge_gen/moonbit.rs b/cli/golem-cli/tests/bridge_gen/moonbit.rs index 64f4c51356..f4ca2a5082 100644 --- a/cli/golem-cli/tests/bridge_gen/moonbit.rs +++ b/cli/golem-cli/tests/bridge_gen/moonbit.rs @@ -2258,7 +2258,7 @@ fn reflection_corpus_drives_generated_runtime_wire_regressions() { .join(", "); let config_expected = case("config/canonical-entry")["expected"].to_string(); let request_expected = format!( - "{{\"appName\":\"app\",\"envName\":\"env\",\"agentTypeName\":\"ConfigAgent\",\"parameters\":{{\"kind\":\"tuple\",\"value\":{{\"elements\":[]}}}},\"config\":[{config_expected}]}}" + "{{\"appName\":\"app\",\"envName\":\"env\",\"agentTypeName\":\"ConfigAgent\",\"parameters\":{{\"kind\":\"tuple\",\"value\":{{\"elements\":[]}}}},\"config\":[{config_expected},{{\"path\":[\"optional\"],\"value\":\"west\"}}]}}" ); let mut agent_type = agent( @@ -2298,13 +2298,54 @@ fn reflection_corpus_drives_generated_runtime_wire_regressions() { value: S64Value(9223372036854775807L), codec: s64_codec, } + let option_codec = public_value_codec( + "{\"root\":{\"kind\":\"option\",\"value\":{\"inner\":{\"kind\":\"string\",\"value\":{}}}}}", + ) + let option_value = OptionValue(Some(StringValue("west"))) + assert_eq( + option_codec.encode(option_value).stringify(), + "{\"$option\":\"some\",\"value\":\"west\"}", + ) + assert_eq(option_codec.encode_canonical(option_value).stringify(), "\"west\"") + let variant_codec = public_value_codec( + "{\"root\":{\"kind\":\"variant\",\"value\":{\"cases\":[{\"name\":\"payload\",\"payload\":{\"kind\":\"string\",\"value\":{}}}]}}}", + ) + assert_eq( + variant_codec + .encode_canonical(VariantValue(0, Some(StringValue("value")))) + .stringify(), + "{\"payload\":\"value\"}", + ) + let result_codec = public_value_codec( + "{\"root\":{\"kind\":\"result\",\"value\":{\"spec\":{\"ok\":{\"kind\":\"string\",\"value\":{}}}}}}", + ) + assert_eq( + result_codec + .encode_canonical(ResultValue(ResultOk(Some(StringValue("ready"))))) + .stringify(), + "{\"ok\":\"ready\"}", + ) + let union_codec = public_value_codec( + "{\"root\":{\"kind\":\"union\",\"value\":{\"spec\":{\"branches\":[{\"tag\":\"command\",\"body\":{\"kind\":\"string\",\"value\":{}},\"discriminator\":{\"rule\":\"prefix\",\"value\":{\"prefix\":\"cmd:\"}}}]}}}}", + ) + assert_eq( + union_codec + .encode_canonical(UnionValue("command", StringValue("cmd:run"))) + .stringify(), + "\"cmd:run\"", + ) + let option_entry = AgentConfigEntry::{ + path: ["optional"], + value: option_value, + codec: option_codec, + } let request = encode_create_agent_request( "app", "env", "ConfigAgent", TupleValue([]), None, - [entry], + [entry, option_entry], ) assert_eq(request.stringify(), __REQUEST_EXPECTED__) } diff --git a/cli/golem-cli/tests/bridge_gen/rust.rs b/cli/golem-cli/tests/bridge_gen/rust.rs index abde05c5c3..8639a5cabf 100644 --- a/cli/golem-cli/tests/bridge_gen/rust.rs +++ b/cli/golem-cli/tests/bridge_gen/rust.rs @@ -353,7 +353,7 @@ fn bridge_rust_ephemeral_agent_skips_non_phantom_constructors() { } #[test] -fn bridge_rust_external_rest_config_uses_schema_guided_public_json() { +fn bridge_rust_external_rest_config_uses_canonical_json_and_session_public_json() { let dir = TempDir::new().unwrap(); let target_dir = Utf8Path::from_path(dir.path()).unwrap(); let mut agent_type = agent( @@ -386,10 +386,14 @@ fn bridge_rust_external_rest_config_uses_schema_guided_public_json() { let source = std::fs::read_to_string(package_dir.join("src/lib.rs")).unwrap(); assert!(source.contains( - "let __config_json = golem_client::invocation_session::encode_generated_streamless_value(" + "let __public_config_json = golem_client::invocation_session::encode_generated_streamless_value(" )); - assert!(source.contains("value: __config_json.clone()")); - assert!(source.contains("value: __config_json.into()")); + assert!( + source + .contains("let __canonical_config_json = golem_common::schema::render::to_json_value(") + ); + assert!(source.contains("value: __public_config_json")); + assert!(source.contains("value: __canonical_config_json.into()")); assert!(!source.contains("serde_json::to_value(&__config_value)")); } diff --git a/cli/golem-cli/tests/bridge_gen/scala.rs b/cli/golem-cli/tests/bridge_gen/scala.rs index adcc0aaadd..505d636b5e 100644 --- a/cli/golem-cli/tests/bridge_gen/scala.rs +++ b/cli/golem-cli/tests/bridge_gen/scala.rs @@ -552,17 +552,50 @@ class StreamRuntimeTest extends munit.FunSuite { val s64Codec = PublicValueCodec.fromSchemaGraphJson( """{"root":{"kind":"s64","value":{}}}""" ) + val optionCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"option","value":{"inner":{"kind":"string","value":{}}}}}""" + ) + val optionValue = SchemaValue.OptionValue(Some(SchemaValue.StringValue("west"))) + assertEquals(optionCodec.encode(optionValue).render, """{"$option":"some","value":"west"}""") + assertEquals(optionCodec.encodeCanonical(optionValue).render, """"west"""") + val variantCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"variant","value":{"cases":[{"name":"payload","payload":{"kind":"string","value":{}}}]}}}""" + ) + assertEquals( + variantCodec.encodeCanonical(SchemaValue.VariantValue(0, Some(SchemaValue.StringValue("value")))).render, + """{"payload":"value"}""" + ) + val resultCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"result","value":{"spec":{"ok":{"kind":"string","value":{}}}}}}""" + ) + assertEquals( + resultCodec.encodeCanonical(SchemaValue.ResultValue(SchemaResult.Ok(Some(SchemaValue.StringValue("ready"))))).render, + """{"ok":"ready"}""" + ) + val unionCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"union","value":{"spec":{"branches":[{"tag":"command","body":{"kind":"string","value":{}},"discriminator":{"rule":"prefix","value":{"prefix":"cmd:"}}}]}}}}""" + ) + assertEquals( + unionCodec.encodeCanonical(SchemaValue.UnionValue("command", SchemaValue.StringValue("cmd:run"))).render, + """"cmd:run"""" + ) val request = CreateAgentRequest( "app", "env", "ConfigAgent", SchemaValue.TupleValue(List.empty), None, - List(AgentConfigEntry(path, SchemaValue.S64Value(Long.MaxValue), s64Codec)) + List( + AgentConfigEntry(path, SchemaValue.S64Value(Long.MaxValue), s64Codec), + AgentConfigEntry(List("optional"), optionValue, optionCodec) + ) ) val envelope = BridgeProtocol.encodeCreateAgentRequest(request) val config = Json.asArray(Json.requireField(envelope, "config").toOption.get).toOption.get - assertEquals(config, Vector(configExpected)) + assertEquals( + config, + Vector(configExpected, Json.parse("""{"path":["optional"],"value":"west"}""").toOption.get) + ) assertEquals(Json.requireField(config.head, "value").toOption.get, Json.string(Long.MaxValue.toString)) } diff --git a/cli/golem-cli/tests/bridge_gen/typescript.rs b/cli/golem-cli/tests/bridge_gen/typescript.rs index 32df61e368..133ef89850 100644 --- a/cli/golem-cli/tests/bridge_gen/typescript.rs +++ b/cli/golem-cli/tests/bridge_gen/typescript.rs @@ -305,7 +305,7 @@ fn static_and_instance_agent_methods_can_share_names() { } #[test] -fn external_rest_config_uses_schema_guided_public_json() { +fn external_rest_config_uses_canonical_json_for_named_non_streaming_values() { let dir = TempDir::new().unwrap(); let target = Utf8Path::from_path(dir.path()).unwrap(); let mut agent_type = agent( @@ -313,10 +313,45 @@ fn external_rest_config_uses_schema_guided_public_json() { "typescript", vec![], vec![], - vec![], + vec![def( + "config-shape", + SchemaType::record(vec![ + named_field("maybe-region", SchemaType::option(SchemaType::string())), + named_field( + "outcome", + SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: None, + }), + ), + named_field( + "mode", + SchemaType::variant(vec![ + variant_case("off", None), + variant_case("on", Some(SchemaType::string())), + ]), + ), + named_field( + "target", + SchemaType::union(UnionSpec { + branches: vec![UnionBranch { + tag: "command".to_string(), + body: SchemaType::string(), + discriminator: DiscriminatorRule::Prefix { + prefix: "cmd:".to_string(), + }, + metadata: MetadataEnvelope::default(), + }], + }), + ), + ]), + )], AgentMode::Durable, ); - agent_type.config = vec![local_config(vec!["limits", "maximum"], SchemaType::u64())]; + agent_type.config = vec![local_config( + vec!["limits", "maximum"], + ref_to("config-shape"), + )]; let package_dir = target.join("config-agent-client"); TypeScriptBridgeGenerator::new_with_mode( agent_type, @@ -329,21 +364,20 @@ fn external_rest_config_uses_schema_guided_public_json() { .unwrap(); let source = std::fs::read_to_string(package_dir.join("config-agent-client.ts")).unwrap(); - let config_lines = source + assert!(source.contains("function encodeCanonicalConfigShape(value: ConfigShape): any")); + assert!(source.contains("value: encodeCanonicalConfigShape(configLimitsMaximum)")); + assert!( + source.contains("function encodePublicConfigShape(value: ConfigShape, stream: any): any") + ); + let canonical = source .lines() - .filter(|line| line.contains(".push({ path: [\"limits\",\"maximum\"]")) - .collect::>(); - assert!(!config_lines.is_empty(), "missing generated config entry"); - for line in config_lines { - assert!( - line.contains(".toString()"), - "config is not public JSON: {line}" - ); - assert!( - !line.contains("kind:"), - "config is tagged schema JSON: {line}" - ); - } + .find(|line| line.starts_with("function encodeCanonicalConfigShape")) + .expect("missing canonical config encoder"); + assert!(canonical.contains("'ok' in v ? { ok:")); + assert!(canonical.contains("unknown variant")); + assert!(canonical.contains("if(v.tag === \"command\") return")); + assert!(source.contains("void (")); + install_and_build(&package_dir); } #[test] diff --git a/golem-common/src/schema/render/mod.rs b/golem-common/src/schema/render/mod.rs index 2d97cd81ea..f4ae60672f 100644 --- a/golem-common/src/schema/render/mod.rs +++ b/golem-common/src/schema/render/mod.rs @@ -31,4 +31,5 @@ pub use cli_text::{ value_to_cli_text_with_secret_metadata, }; pub use docs::graph_to_markdown; +pub use golem_schema::schema::render::to_json_value; pub use json_schema::{input_schema_to_json_schema, output_schema_to_json_schema}; diff --git a/golem-worker-service/src/service/worker/service.rs b/golem-worker-service/src/service/worker/service.rs index a6022ba6af..a0d74b0826 100644 --- a/golem-worker-service/src/service/worker/service.rs +++ b/golem-worker-service/src/service/worker/service.rs @@ -44,8 +44,9 @@ use golem_common::base_model::json::NormalizedJsonValue; use golem_common::model::AgentInvocationOutput; use golem_common::model::account::AccountId; use golem_common::model::agent::{ - AgentMode, AgentTypeName, GolemUserPrincipal, InvocationFreshnessDisposition, OwnerKind, - ParsedAgentId, Principal, ephemeral_invocation_phantom_id, + AgentConfigSource, AgentMode, AgentTypeName, GolemUserPrincipal, + InvocationFreshnessDisposition, OwnerKind, ParsedAgentId, Principal, + ephemeral_invocation_phantom_id, }; use golem_common::model::application::ApplicationName; use golem_common::model::card::owner::{AgentOwnerLeafPattern, AgentOwnerPattern}; @@ -64,7 +65,8 @@ use golem_common::model::filesystem::{ FileByteSelection, FileReadError, FileReadHead, validate_file_read_path, }; use golem_common::model::invocation_session_public::{ - InvocationSelector, PublicConfigEntry, PublicNativeToolTarget, PublicTypedValue, + InvocationSelector, PublicConfigEntry, PublicErrorCode, PublicNativeToolTarget, + PublicTypedValue, }; use golem_common::model::oplog::OplogCursor; use golem_common::model::oplog::OplogIndex; @@ -73,6 +75,7 @@ use golem_common::model::worker::AgentConfigEntryDto; use golem_common::model::worker::AgentUpdateMode; use golem_common::model::worker::{AgentMetadataDto, ResolvedRevert, RevertWorkerTarget}; use golem_common::model::{AgentFilter, AgentFingerprint, AgentId, IdempotencyKey, ScanCursor}; +use golem_common::schema::agent::AgentConfigDeclarationSchema; use golem_common::schema::json_input_schema_value_to_typed_schema_value; use golem_common::schema::public_json::{ PublicSchemaValueError, PublicStreamReference, PublicStreamReferencePolicy, @@ -146,6 +149,54 @@ fn public_input_graph(graph: &SchemaGraph, input_schema: &InputSchema) -> Schema } } +fn decode_public_agent_config( + graph: &SchemaGraph, + declarations: &[AgentConfigDeclarationSchema], + entries: Vec, +) -> Result, PublicSchemaValueError> { + entries + .into_iter() + .map(|entry| { + let declaration = declarations + .iter() + .find(|declaration| { + declaration.source == AgentConfigSource::Local && declaration.path == entry.path + }) + .ok_or_else(|| { + PublicSchemaValueError::new( + PublicErrorCode::ValidationError, + format!( + "agent type does not declare local config {}", + entry.path.join(".") + ), + ) + })?; + let value = decode_public_json_schema_value( + graph, + &declaration.value_type, + &entry.value, + PublicStreamReferencePolicy::None, + |_, _| unreachable!("configuration stream references are disabled by policy"), + )?; + let value = + golem_schema::schema::render::to_json_value(graph, &declaration.value_type, &value) + .map_err(|error| { + PublicSchemaValueError::new( + PublicErrorCode::ValidationError, + format!( + "config value for path {} cannot be encoded canonically: {error}", + entry.path.join(".") + ), + ) + })?; + Ok(AgentConfigEntryDto { + path: entry.path, + value: NormalizedJsonValue::new(value), + }) + }) + .collect() +} + fn public_invocation_graph( graph: &SchemaGraph, input_schema: &InputSchema, @@ -2011,15 +2062,6 @@ impl WorkerService { let agent_type_name = AgentTypeName(start.selector.agent_type); let method_name = start.selector.method; let idempotency_key = IdempotencyKey::new(start.idempotency_key); - let config = start - .config - .into_iter() - .map(|entry| AgentConfigEntryDto { - path: entry.path, - value: NormalizedJsonValue::new(entry.value), - }) - .collect::>(); - let resolved = self .agent_resolution_cache .resolve(&app_name, &env_name, &agent_type_name, None, &auth) @@ -2029,6 +2071,8 @@ impl WorkerService { let environment_id = resolved.environment_id; let component_id = registered_agent_type.implemented_by.component_id; let agent_type = ®istered_agent_type.agent_type; + let config = + decode_public_agent_config(&agent_type.schema, &agent_type.config, start.config)?; let constructor_graph = public_input_graph(&agent_type.schema, &agent_type.constructor.input_schema); let constructor_parameters = decode_public_json_schema_value( @@ -3525,7 +3569,8 @@ mod tests { use super::{ PublicAgentSessionStart, PublicAgentSessionStartError, WorkerService, agent_verb_for_invocation_mode, build_public_agent_id, build_public_invocation_agent_id, - decode_public_schema_value, normalize_agent_invocation_identity, + decode_public_agent_config, decode_public_schema_value, + normalize_agent_invocation_identity, }; use crate::api::agents::{ AgentInvocationMode, AgentInvocationRequest, CreateAgentRequest, NativeToolDescribeRequest, @@ -3550,7 +3595,7 @@ mod tests { use golem_common::model::Empty; use golem_common::model::account::{AccountEmail, AccountId}; use golem_common::model::agent::{ - AgentMode, AgentTypeName, GolemUserPrincipal, HttpEndpointDetails, + AgentConfigSource, AgentMode, AgentTypeName, GolemUserPrincipal, HttpEndpointDetails, InvocationFreshnessDisposition, OwnerKind, ParsedAgentId, Principal, RegisteredAgentType, RegisteredAgentTypeImplementer, ResolvedAgentType, Snapshotting, ephemeral_invocation_phantom_id, @@ -3573,7 +3618,7 @@ mod tests { use golem_common::model::diff::Hash; use golem_common::model::environment::{EnvironmentId, EnvironmentName}; use golem_common::model::filesystem::{FileByteSelection, FileReadHead}; - use golem_common::model::invocation_session_public::InvocationSelector; + use golem_common::model::invocation_session_public::{InvocationSelector, PublicConfigEntry}; use golem_common::model::json::NormalizedJsonValue; use golem_common::model::oplog::{OplogCursor, OplogIndex}; use golem_common::model::tool::{ToolBindingOwner, ToolName}; @@ -3584,6 +3629,7 @@ mod tests { use golem_common::model::{ AgentFilter, AgentFingerprint, AgentId, AgentStatus, IdempotencyKey, ScanCursor, Timestamp, }; + use golem_common::schema::agent::AgentConfigDeclarationSchema; use golem_common::schema::public_json::PublicStreamReference; use golem_common::schema::stream::SchemaValueStream; use golem_common::schema::{ @@ -3609,6 +3655,77 @@ mod tests { ) } + #[test] + fn public_agent_config_is_transcoded_to_canonical_json() { + let graph = SchemaGraph::anonymous(SchemaType::string()); + let declarations = vec![ + AgentConfigDeclarationSchema { + source: AgentConfigSource::Local, + path: vec!["optional".to_string()], + value_type: SchemaType::option(SchemaType::string()), + }, + AgentConfigDeclarationSchema { + source: AgentConfigSource::Local, + path: vec!["result".to_string()], + value_type: SchemaType::result(golem_common::schema::ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: None, + }), + }, + AgentConfigDeclarationSchema { + source: AgentConfigSource::Local, + path: vec!["variant".to_string()], + value_type: SchemaType::variant(vec![golem_common::schema::VariantCaseType { + name: "payload".to_string(), + payload: Some(SchemaType::string()), + metadata: Default::default(), + }]), + }, + AgentConfigDeclarationSchema { + source: AgentConfigSource::Local, + path: vec!["union".to_string()], + value_type: SchemaType::union(golem_common::schema::UnionSpec { + branches: vec![golem_common::schema::UnionBranch { + tag: "command".to_string(), + body: SchemaType::string(), + discriminator: golem_common::schema::DiscriminatorRule::Prefix { + prefix: "cmd:".to_string(), + }, + metadata: Default::default(), + }], + }), + }, + ]; + let config = decode_public_agent_config( + &graph, + &declarations, + vec![ + PublicConfigEntry { + path: vec!["optional".to_string()], + value: serde_json::json!({"$option": "some", "value": "west"}), + }, + PublicConfigEntry { + path: vec!["result".to_string()], + value: serde_json::json!({"$result": "ok", "value": "ready"}), + }, + PublicConfigEntry { + path: vec!["variant".to_string()], + value: serde_json::json!({"$case": "payload", "value": "value"}), + }, + PublicConfigEntry { + path: vec!["union".to_string()], + value: serde_json::json!({"$union": "command", "value": "cmd:run"}), + }, + ], + ) + .unwrap(); + + assert_eq!(config[0].value.0, serde_json::json!("west")); + assert_eq!(config[1].value.0, serde_json::json!({"ok": "ready"})); + assert_eq!(config[2].value.0, serde_json::json!({"payload": "value"})); + assert_eq!(config[3].value.0, serde_json::json!("cmd:run")); + } + struct TestAgentTypeResolver(AgentMode); impl golem_common::model::agent::AgentTypeSchemaResolver for TestAgentTypeResolver { From e9a4773f2491abf40bdc46b8a752bdd1ff94a126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 25 Sep 2026 12:41:23 +0200 Subject: [PATCH 31/37] fix: align reflection validation edge cases --- .../schema/render/tests/json_schema_tests.rs | 5 +- golem-schema/src/schema/render/json_schema.rs | 2 +- .../src/internal/reflection/schemaRender.ts | 2 +- .../golem_sdk/reflection/json_schema.mbt | 7 ++- .../reflection_conformance_corpus_wbtest.mbt | 2 +- .../golem_sdk/reflection/schema_ref.mbt | 55 +++++++++++++---- .../reflection/schema_ref_wbtest.mbt | 59 +++++++++++++++++++ .../scala/golem/reflection/SchemaRef.scala | 14 ++++- .../ReflectionConformanceCorpus.scala | 51 ++++++++++++++++ .../golem/reflection/SchemaRefSpec.scala | 21 ++++++- test-data/reflection-conformance/v1.json | 33 +++++++++++ 11 files changed, 228 insertions(+), 23 deletions(-) diff --git a/golem-common/src/schema/render/tests/json_schema_tests.rs b/golem-common/src/schema/render/tests/json_schema_tests.rs index b61a2fe3f6..a4ad75ca86 100644 --- a/golem-common/src/schema/render/tests/json_schema_tests.rs +++ b/golem-common/src/schema/render/tests/json_schema_tests.rs @@ -373,7 +373,7 @@ fn binary_emits_canonical_object_shape() { schema["properties"]["mimeType"]["pattern"] .as_str() .unwrap(), - "^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$" + "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$" ); assert_eq!(schema["additionalProperties"], json!(false)); } @@ -1060,8 +1060,9 @@ mod agent_entry_points { // Canonical Text shape: `{ text, language? }`. assert_eq!(props["text"]["type"], json!("string")); assert!(props.contains_key("language")); + assert_eq!(props["language"]["enum"], json!(["en", "fr"])); assert_eq!(doc["required"], json!(["text"])); - assert_eq!(doc["description"], json!("Allowed languages: en, fr")); + assert!(doc.get("description").is_none()); } #[test] diff --git a/golem-schema/src/schema/render/json_schema.rs b/golem-schema/src/schema/render/json_schema.rs index 7753b824d9..7ce169a199 100644 --- a/golem-schema/src/schema/render/json_schema.rs +++ b/golem-schema/src/schema/render/json_schema.rs @@ -26,7 +26,7 @@ use serde_json::{Map, Number, Value}; use std::collections::{HashMap, HashSet}; const JSON_SCHEMA_DRAFT: &str = "https://json-schema.org/draft/2020-12/schema"; -const MIME_TYPE_PATTERN: &str = "^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$"; +const MIME_TYPE_PATTERN: &str = "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$"; const BASE64URL_PATTERN: &str = "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$"; diff --git a/sdks/effect/src/internal/reflection/schemaRender.ts b/sdks/effect/src/internal/reflection/schemaRender.ts index eb45ea8d9a..0fab89c35e 100644 --- a/sdks/effect/src/internal/reflection/schemaRender.ts +++ b/sdks/effect/src/internal/reflection/schemaRender.ts @@ -1045,7 +1045,7 @@ function discriminatorMatches(rule: { tag: string; val?: unknown }, value: JsonV } return false } -const MIME_TYPE_PATTERN = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/u +const MIME_TYPE_PATTERN = new RegExp("^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$", "u") const BASE64URL_PATTERN = "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$" diff --git a/sdks/moonbit/golem_sdk/reflection/json_schema.mbt b/sdks/moonbit/golem_sdk/reflection/json_schema.mbt index d462cdf23a..15a9eb752a 100644 --- a/sdks/moonbit/golem_sdk/reflection/json_schema.mbt +++ b/sdks/moonbit/golem_sdk/reflection/json_schema.mbt @@ -501,7 +501,12 @@ fn schema_json(graph : @model.SchemaGraph, schema : @model.SchemaType) -> Json { ) None => () } - let mime : Map[String, Json] = { "type": Json::string("string") } + let mime : Map[String, Json] = { + "type": Json::string("string"), + "pattern": Json::string( + "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$", + ), + } match restrictions.mime_types { Some(values) => mime["enum"] = schema_strings(values) None => () diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt index cf9ff7c5ee..b363230524 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt @@ -9,4 +9,4 @@ // Generated by scripts/sync-reflection-conformance.mjs. Do not edit. ///| -let reflection_conformance_corpus_json : String = "{\n \"version\": \"1.0.0\",\n \"schemaKinds\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ],\n \"restrictionKinds\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ],\n \"caseIds\": [\n \"canonical/s64-min\",\n \"canonical/s64-max\",\n \"canonical/u64-max\",\n \"canonical/duration-min\",\n \"canonical/duration-max\",\n \"canonical/quantity-wide-mantissa\",\n \"canonical/omitted-options\",\n \"canonical/binary-mime\",\n \"errors/s64-plus\",\n \"errors/s64-leading-zero\",\n \"errors/s64-negative-zero\",\n \"errors/s64-overflow\",\n \"errors/u64-negative\",\n \"errors/u64-overflow\",\n \"errors/wide-wrong-json-type\",\n \"errors/duration-legacy-string\",\n \"errors/quantity-negative-zero\",\n \"errors/quantity-extra-field\",\n \"errors/binary-noncanonical-base64\",\n \"json-schema/s64\",\n \"json-schema/s64-restrictions\",\n \"json-schema/u32-restrictions\",\n \"json-schema/f64-restrictions\",\n \"json-schema/text-restrictions\",\n \"json-schema/binary-restrictions\",\n \"json-schema/u64\",\n \"json-schema/duration-nanoseconds\",\n \"json-schema/quantity-mantissa\",\n \"json-schema/optional-required\",\n \"json-schema/unsupported-leaves\",\n \"schema/all-kinds\",\n \"schema/all-restrictions\",\n \"graph/references-validation-equivalence\",\n \"tool/canonical-input\",\n \"config/canonical-entry\",\n \"constraints/rejected-value\",\n \"compatibility/result\",\n \"compatibility/custom-error\"\n ],\n \"cases\": [\n {\n \"id\": \"canonical/s64-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"-9223372036854775808\",\n \"expected\": \"-9223372036854775808\"\n },\n {\n \"id\": \"canonical/s64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775807\",\n \"expected\": \"9223372036854775807\"\n },\n {\n \"id\": \"canonical/u64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551615\",\n \"expected\": \"18446744073709551615\"\n },\n {\n \"id\": \"canonical/duration-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"-9223372036854775808\"\n },\n \"expected\": {\n \"nanoseconds\": \"-9223372036854775808\"\n }\n },\n {\n \"id\": \"canonical/duration-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"9223372036854775807\"\n },\n \"expected\": {\n \"nanoseconds\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"canonical/quantity-wide-mantissa\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n }\n },\n {\n \"id\": \"canonical/omitted-options\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"optional-record\",\n \"input\": {},\n \"expected\": {\n \"direct\": null,\n \"referenced\": null\n }\n },\n {\n \"id\": \"canonical/binary-mime\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"binary\",\n \"input\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n },\n \"expected\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n }\n },\n {\n \"id\": \"errors/s64-plus\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"+1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-leading-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"01\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"-0\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775808\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-negative\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"-1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551616\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/wide-wrong-json-type\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": 1,\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/duration-legacy-string\",\n \"operation\": \"reject\",\n \"fixture\": \"duration\",\n \"input\": \"PT1S\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-0\",\n \"scale\": 0,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-extra-field\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"1\",\n \"scale\": 0,\n \"unit\": \"m\",\n \"extra\": true\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/binary-noncanonical-base64\",\n \"operation\": \"reject\",\n \"fixture\": \"binary\",\n \"inputs\": [\n {\n \"bytes\": \"+/8\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8=\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_9\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"A\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8\",\n \"mime_type\": \"application/octet-stream\"\n }\n ],\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"json-schema/s64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/s64-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9007199254740993\",\n \"x-golem-maximum\": \"9007199254740993\"\n }\n },\n {\n \"id\": \"json-schema/u64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"u64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"uint64\",\n \"pattern\": \"^(?:0|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"0\",\n \"x-golem-maximum\": \"18446744073709551615\"\n }\n },\n {\n \"id\": \"json-schema/u32-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-u32\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"integer\",\n \"minimum\": 2,\n \"maximum\": 10\n }\n },\n {\n \"id\": \"json-schema/f64-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-f64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"number\",\n \"minimum\": -1.5,\n \"maximum\": 2.5\n }\n },\n {\n \"id\": \"json-schema/text-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-text\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"object\",\n \"properties\": {\n \"text\": {\n \"type\": \"string\",\n \"minLength\": 2,\n \"maxLength\": 8,\n \"pattern\": \"^[a-z]+$\"\n },\n \"language\": {\n \"type\": \"string\",\n \"enum\": [\n \"en\",\n \"de\"\n ]\n }\n },\n \"required\": [\n \"text\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"id\": \"json-schema/binary-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-binary\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"object\",\n \"properties\": {\n \"bytes\": {\n \"type\": \"string\",\n \"contentEncoding\": \"base64url\",\n \"pattern\": \"^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$\",\n \"minLength\": 3,\n \"maxLength\": 6\n },\n \"mimeType\": {\n \"type\": \"string\",\n \"enum\": [\n \"image/png\",\n \"application/octet-stream\"\n ]\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"id\": \"json-schema/duration-nanoseconds\",\n \"operation\": \"json-schema\",\n \"fixture\": \"duration\",\n \"path\": \"/properties/nanoseconds\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/quantity-mantissa\",\n \"operation\": \"json-schema\",\n \"fixture\": \"quantity\",\n \"path\": \"/properties/mantissa\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/optional-required\",\n \"operation\": \"json-schema\",\n \"fixture\": \"optional-record\",\n \"path\": \"/required\",\n \"expected\": []\n },\n {\n \"id\": \"json-schema/unsupported-leaves\",\n \"operation\": \"semantic\",\n \"fixture\": \"unsupported-leaves\",\n \"expected\": {\n \"count\": 5,\n \"schema\": {\n \"not\": {}\n }\n }\n },\n {\n \"id\": \"schema/all-kinds\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-kinds\",\n \"expected\": {\n \"names\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ]\n }\n },\n {\n \"id\": \"schema/all-restrictions\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-restrictions\",\n \"expected\": {\n \"names\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ]\n }\n },\n {\n \"id\": \"graph/references-validation-equivalence\",\n \"operation\": \"semantic\",\n \"fixture\": \"graph\",\n \"expected\": {\n \"valid\": true,\n \"equivalent\": true\n }\n },\n {\n \"id\": \"tool/canonical-input\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"tool-input\",\n \"input\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ]\n },\n \"expected\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ],\n \"ignoreCase\": null\n }\n },\n {\n \"id\": \"config/canonical-entry\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"config-entry\",\n \"input\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n },\n \"expected\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"constraints/rejected-value\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-u32\",\n \"input\": 11,\n \"expected\": {\n \"kind\": \"constraint-violation\"\n }\n },\n {\n \"id\": \"compatibility/result\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"result\",\n \"input\": {\n \"ok\": \"ready\"\n },\n \"expected\": {\n \"ok\": \"ready\"\n }\n },\n {\n \"id\": \"compatibility/custom-error\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"custom-error\",\n \"input\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n },\n \"expected\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n }\n }\n ]\n}\n" +let reflection_conformance_corpus_json : String = "{\n \"version\": \"1.0.0\",\n \"schemaKinds\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ],\n \"restrictionKinds\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ],\n \"caseIds\": [\n \"canonical/s64-min\",\n \"canonical/s64-max\",\n \"canonical/u64-max\",\n \"canonical/duration-min\",\n \"canonical/duration-max\",\n \"canonical/quantity-wide-mantissa\",\n \"canonical/omitted-options\",\n \"canonical/binary-mime\",\n \"canonical/constrained-text-without-language\",\n \"canonical/constrained-binary-without-mime\",\n \"errors/s64-plus\",\n \"errors/s64-leading-zero\",\n \"errors/s64-negative-zero\",\n \"errors/s64-overflow\",\n \"errors/u64-negative\",\n \"errors/u64-overflow\",\n \"errors/wide-wrong-json-type\",\n \"errors/duration-legacy-string\",\n \"errors/quantity-negative-zero\",\n \"errors/quantity-extra-field\",\n \"errors/binary-noncanonical-base64\",\n \"errors/binary-invalid-mime\",\n \"errors/text-extra-field\",\n \"json-schema/s64\",\n \"json-schema/s64-restrictions\",\n \"json-schema/u32-restrictions\",\n \"json-schema/f64-restrictions\",\n \"json-schema/text-restrictions\",\n \"json-schema/binary-restrictions\",\n \"json-schema/u64\",\n \"json-schema/duration-nanoseconds\",\n \"json-schema/quantity-mantissa\",\n \"json-schema/optional-required\",\n \"json-schema/unsupported-leaves\",\n \"schema/all-kinds\",\n \"schema/all-restrictions\",\n \"graph/references-validation-equivalence\",\n \"tool/canonical-input\",\n \"config/canonical-entry\",\n \"constraints/rejected-value\",\n \"compatibility/result\",\n \"compatibility/custom-error\"\n ],\n \"cases\": [\n {\n \"id\": \"canonical/s64-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"-9223372036854775808\",\n \"expected\": \"-9223372036854775808\"\n },\n {\n \"id\": \"canonical/s64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775807\",\n \"expected\": \"9223372036854775807\"\n },\n {\n \"id\": \"canonical/u64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551615\",\n \"expected\": \"18446744073709551615\"\n },\n {\n \"id\": \"canonical/duration-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"-9223372036854775808\"\n },\n \"expected\": {\n \"nanoseconds\": \"-9223372036854775808\"\n }\n },\n {\n \"id\": \"canonical/duration-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"9223372036854775807\"\n },\n \"expected\": {\n \"nanoseconds\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"canonical/quantity-wide-mantissa\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n }\n },\n {\n \"id\": \"canonical/omitted-options\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"optional-record\",\n \"input\": {},\n \"expected\": {\n \"direct\": null,\n \"referenced\": null\n }\n },\n {\n \"id\": \"canonical/binary-mime\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"binary\",\n \"input\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n },\n \"expected\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n }\n },\n {\n \"id\": \"canonical/constrained-text-without-language\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"constrained-text\",\n \"input\": {\n \"text\": \"hello\"\n },\n \"expected\": {\n \"text\": \"hello\"\n }\n },\n {\n \"id\": \"canonical/constrained-binary-without-mime\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"constrained-binary\",\n \"input\": {\n \"bytes\": \"AQI\"\n },\n \"expected\": {\n \"bytes\": \"AQI\"\n }\n },\n {\n \"id\": \"errors/s64-plus\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"+1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-leading-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"01\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"-0\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775808\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-negative\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"-1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551616\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/wide-wrong-json-type\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": 1,\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/duration-legacy-string\",\n \"operation\": \"reject\",\n \"fixture\": \"duration\",\n \"input\": \"PT1S\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-0\",\n \"scale\": 0,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-extra-field\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"1\",\n \"scale\": 0,\n \"unit\": \"m\",\n \"extra\": true\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/binary-noncanonical-base64\",\n \"operation\": \"reject\",\n \"fixture\": \"binary\",\n \"inputs\": [\n {\n \"bytes\": \"+/8\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8=\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_9\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"A\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8\",\n \"mime_type\": \"application/octet-stream\"\n }\n ],\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/binary-invalid-mime\",\n \"operation\": \"reject\",\n \"fixture\": \"binary\",\n \"input\": {\n \"bytes\": \"AQ\",\n \"mimeType\": \"not a mime\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/text-extra-field\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-text\",\n \"input\": {\n \"text\": \"hello\",\n \"extra\": true\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"json-schema/s64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/s64-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9007199254740993\",\n \"x-golem-maximum\": \"9007199254740993\"\n }\n },\n {\n \"id\": \"json-schema/u64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"u64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"uint64\",\n \"pattern\": \"^(?:0|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"0\",\n \"x-golem-maximum\": \"18446744073709551615\"\n }\n },\n {\n \"id\": \"json-schema/u32-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-u32\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"integer\",\n \"minimum\": 2,\n \"maximum\": 10\n }\n },\n {\n \"id\": \"json-schema/f64-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-f64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"number\",\n \"minimum\": -1.5,\n \"maximum\": 2.5\n }\n },\n {\n \"id\": \"json-schema/text-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-text\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"object\",\n \"properties\": {\n \"text\": {\n \"type\": \"string\",\n \"minLength\": 2,\n \"maxLength\": 8,\n \"pattern\": \"^[a-z]+$\"\n },\n \"language\": {\n \"type\": \"string\",\n \"enum\": [\n \"en\",\n \"de\"\n ]\n }\n },\n \"required\": [\n \"text\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"id\": \"json-schema/binary-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-binary\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"object\",\n \"properties\": {\n \"bytes\": {\n \"type\": \"string\",\n \"contentEncoding\": \"base64url\",\n \"pattern\": \"^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$\",\n \"minLength\": 3,\n \"maxLength\": 6\n },\n \"mimeType\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Za-z0-9!#$&^_.+\\\\-]+\\\\/[A-Za-z0-9!#$&^_.+\\\\-]+$\",\n \"enum\": [\n \"image/png\",\n \"application/octet-stream\"\n ]\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"id\": \"json-schema/duration-nanoseconds\",\n \"operation\": \"json-schema\",\n \"fixture\": \"duration\",\n \"path\": \"/properties/nanoseconds\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/quantity-mantissa\",\n \"operation\": \"json-schema\",\n \"fixture\": \"quantity\",\n \"path\": \"/properties/mantissa\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/optional-required\",\n \"operation\": \"json-schema\",\n \"fixture\": \"optional-record\",\n \"path\": \"/required\",\n \"expected\": []\n },\n {\n \"id\": \"json-schema/unsupported-leaves\",\n \"operation\": \"semantic\",\n \"fixture\": \"unsupported-leaves\",\n \"expected\": {\n \"count\": 5,\n \"schema\": {\n \"not\": {}\n }\n }\n },\n {\n \"id\": \"schema/all-kinds\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-kinds\",\n \"expected\": {\n \"names\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ]\n }\n },\n {\n \"id\": \"schema/all-restrictions\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-restrictions\",\n \"expected\": {\n \"names\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ]\n }\n },\n {\n \"id\": \"graph/references-validation-equivalence\",\n \"operation\": \"semantic\",\n \"fixture\": \"graph\",\n \"expected\": {\n \"valid\": true,\n \"equivalent\": true\n }\n },\n {\n \"id\": \"tool/canonical-input\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"tool-input\",\n \"input\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ]\n },\n \"expected\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ],\n \"ignoreCase\": null\n }\n },\n {\n \"id\": \"config/canonical-entry\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"config-entry\",\n \"input\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n },\n \"expected\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"constraints/rejected-value\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-u32\",\n \"input\": 11,\n \"expected\": {\n \"kind\": \"constraint-violation\"\n }\n },\n {\n \"id\": \"compatibility/result\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"result\",\n \"input\": {\n \"ok\": \"ready\"\n },\n \"expected\": {\n \"ok\": \"ready\"\n }\n },\n {\n \"id\": \"compatibility/custom-error\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"custom-error\",\n \"input\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n },\n \"expected\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n }\n }\n ]\n}\n" diff --git a/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt b/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt index a9398694bd..f057a46c11 100644 --- a/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt +++ b/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt @@ -357,11 +357,12 @@ fn SchemaRef::validate_refinements( } match r.languages { Some(allowed) => - if !(match language { - Some(lang) => allowed.contains(lang) - None => false - }) { - raise Schema("text language is not allowed") + match language { + Some(lang) => + if !allowed.contains(lang) { + raise Schema("text language is not allowed") + } + None => () } None => () } @@ -394,11 +395,19 @@ fn SchemaRef::validate_refinements( } match r.mime_types { Some(allowed) => - if !(match mime_type { - Some(mime) => allowed.contains(mime) - None => false - }) { - raise Schema("binary MIME type is not allowed") + match mime_type { + Some(mime) => + if !allowed.contains(mime) { + raise Schema("binary MIME type is not allowed") + } + None => () + } + None => () + } + match mime_type { + Some(mime) => + if !is_valid_mime_type(mime) { + raise Schema("invalid MIME type") } None => () } @@ -605,6 +614,11 @@ fn json_string(value : Json) -> String raise ReflectError { } } +///| +fn is_valid_mime_type(value : String) -> Bool { + re"^[A-Za-z0-9!#$&^_.+\-]+/[A-Za-z0-9!#$&^_.+\-]+$".execute(value) is Some(_) +} + ///| fn json_number(value : Json) -> Double raise ReflectError { match value { @@ -961,7 +975,12 @@ fn SchemaRef::unpack( "bytes": Json::string(binary_to_json(bytes)), } match mime { - Some(mime) => fields["mimeType"] = Json::string(mime) + Some(mime) => { + if !is_valid_mime_type(mime) { + raise Json("invalid MIME type") + } + fields["mimeType"] = Json::string(mime) + } None => () } Json::object(fields) @@ -1189,6 +1208,10 @@ fn SchemaRef::pack( } Text(_) => { let fields = json_object(json) + guard fields.length() <= 2 && + (fields.length() == 1 || fields.contains("language")) else { + raise Json("unexpected text JSON field") + } Text( json_string(required_field(fields, "text")), fields.get("language").map(json_string), @@ -1214,9 +1237,17 @@ fn SchemaRef::pack( (fields.length() == 1 || fields.contains("mimeType")) else { raise Json("unexpected binary JSON field") } + let mime_type = fields.get("mimeType").map(json_string) + match mime_type { + Some(mime) => + if !is_valid_mime_type(mime) { + raise Json("invalid MIME type") + } + None => () + } Binary( binary_from_json(json_string(required_field(fields, "bytes"))), - fields.get("mimeType").map(json_string), + mime_type, ) } Quantity(_) => { diff --git a/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt index f87983ea16..4b7fd84f5c 100644 --- a/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt @@ -121,6 +121,9 @@ test "JSON Schema retains text and binary constraints" { }), "mimeType": Json::object({ "type": Json::string("string"), + "pattern": Json::string( + "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$", + ), "enum": Json::array([Json::string("image/png")]), }), }), @@ -463,6 +466,62 @@ test "binary JSON round-trips unpadded base64url and MIME type" { } noraise { _ => fail("expected padded base64url to be rejected") } + try + schema.pack_json( + Json::object({ + "bytes": Json::string("AQ"), + "mimeType": Json::string("not a mime"), + }), + ) + catch { + _ => () + } noraise { + _ => fail("expected invalid MIME type to be rejected") + } + try + schema.unpack_json( + @model.SchemaValue::Binary(([1] : FixedArray[Byte]), Some("not a mime")), + ) + catch { + _ => () + } noraise { + _ => fail("expected invalid MIME type to be rejected") + } +} + +///| +test "constrained text and binary metadata remain optional" { + let text = test_schema( + Text({ + languages: Some(["en"]), + min_length: None, + max_length: None, + regex: None, + }), + ) + text.validate_value(@model.SchemaValue::Text("hello", None)) + try + text.pack_json( + Json::object({ + "text": Json::string("hello"), + "extra": Json::boolean(true), + }), + ) + catch { + _ => () + } noraise { + _ => fail("expected unknown text field to be rejected") + } + let binary = test_schema( + Binary({ + min_bytes: None, + max_bytes: None, + mime_types: Some(["image/png"]), + }), + ) + binary.validate_value( + @model.SchemaValue::Binary(([1] : FixedArray[Byte]), None), + ) } ///| diff --git a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala index 70ef59a55c..1cbd6dad19 100644 --- a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala @@ -54,6 +54,7 @@ object SchemaRef { private object CanonicalJson { private val SignedIntegerPattern = "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$".r private val UnsignedIntegerPattern = "^(?:0|[1-9][0-9]*)$".r + private val MimeTypePattern = "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$".r private val U64Max = (BigInt(1) << 64) - 1 def pack(graph: SchemaGraph, schema: SchemaType, json: Json): Either[SchemaIssue, SchemaValue] = @@ -232,8 +233,12 @@ private object CanonicalJson { restrictions.minBytes.map(value => "minLength" -> number(base64UrlLength(value))).toList ++ restrictions.maxBytes.map(value => "maxLength" -> number(base64UrlLength(value))).toList val mimeType = restrictions.mimeTypes match { - case Some(values) => typed("string", "enum" -> Json.Array(values.map(Json.String): _*)) - case None => typed("string") + case Some(values) => typed( + "string", + "pattern" -> Json.String(MimeTypePattern.regex), + "enum" -> Json.Array(values.map(Json.String): _*) + ) + case None => typed("string", "pattern" -> Json.String(MimeTypePattern.regex)) } typed( "object", @@ -464,9 +469,11 @@ private object CanonicalJson { case BinaryType(_) => val jsonFields = fields(json) if (!jsonFields.keySet.subsetOf(Set("bytes", "mimeType"))) fail("binary JSON contains unknown fields") + val mimeType = jsonFields.get("mimeType").map(string) + mimeType.foreach(value => if (!MimeTypePattern.pattern.matcher(value).matches()) fail("invalid MIME type")) BinaryValue( decodeBase64Url(string(jsonFields.getOrElse("bytes", fail("missing field 'bytes'")))), - jsonFields.get("mimeType").map(string) + mimeType ) case PathType(_) => PathValue(string(json)) case UrlType(_) => UrlValue(string(json)) @@ -570,6 +577,7 @@ private object CanonicalJson { case (TextType(_), TextValue(text, language)) => Json.Object((List("text" -> Json.String(text)) ++ language.map(value => "language" -> Json.String(value))): _*) case (BinaryType(_), BinaryValue(bytes, mimeType)) => + mimeType.foreach(value => if (!MimeTypePattern.pattern.matcher(value).matches()) fail("invalid MIME type")) Json.Object( (List("bytes" -> Json.String(encodeBase64Url(bytes))) ++ mimeType.map(value => "mimeType" -> Json.String(value) diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala index 2b3285575e..31ea573971 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala @@ -88,6 +88,8 @@ private[reflection] object ReflectionConformanceCorpus { "canonical/quantity-wide-mantissa", "canonical/omitted-options", "canonical/binary-mime", + "canonical/constrained-text-without-language", + "canonical/constrained-binary-without-mime", "errors/s64-plus", "errors/s64-leading-zero", "errors/s64-negative-zero", @@ -99,6 +101,8 @@ private[reflection] object ReflectionConformanceCorpus { "errors/quantity-negative-zero", "errors/quantity-extra-field", "errors/binary-noncanonical-base64", + "errors/binary-invalid-mime", + "errors/text-extra-field", "json-schema/s64", "json-schema/s64-restrictions", "json-schema/u32-restrictions", @@ -201,6 +205,28 @@ private[reflection] object ReflectionConformanceCorpus { "mimeType": "application/octet-stream" } }, + { + "id": "canonical/constrained-text-without-language", + "operation": "roundtrip", + "fixture": "constrained-text", + "input": { + "text": "hello" + }, + "expected": { + "text": "hello" + } + }, + { + "id": "canonical/constrained-binary-without-mime", + "operation": "roundtrip", + "fixture": "constrained-binary", + "input": { + "bytes": "AQI" + }, + "expected": { + "bytes": "AQI" + } + }, { "id": "errors/s64-plus", "operation": "reject", @@ -330,6 +356,30 @@ private[reflection] object ReflectionConformanceCorpus { "kind": "invalid-json" } }, + { + "id": "errors/binary-invalid-mime", + "operation": "reject", + "fixture": "binary", + "input": { + "bytes": "AQ", + "mimeType": "not a mime" + }, + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/text-extra-field", + "operation": "reject", + "fixture": "constrained-text", + "input": { + "text": "hello", + "extra": true + }, + "expected": { + "kind": "invalid-json" + } + }, { "id": "json-schema/s64", "operation": "json-schema", @@ -436,6 +486,7 @@ private[reflection] object ReflectionConformanceCorpus { }, "mimeType": { "type": "string", + "pattern": "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$", "enum": [ "image/png", "application/octet-stream" diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala index 01f90e4dc8..35e4bc756e 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala @@ -147,6 +147,7 @@ object SchemaRefSpec extends ZIOSpecDefault { val text = textProperties.get("text").one val binaryProperties = properties.get("content").one.toOption.get.get("properties").one.toOption.get val bytes = binaryProperties.get("bytes").one + val mimeType = binaryProperties.get("mimeType").one val pattern = bytes.flatMap(_.get("pattern").one) match { case Right(Json.String(value)) => value case other => throw new AssertionError(s"expected binary pattern, got $other") @@ -164,8 +165,9 @@ object SchemaRefSpec extends ZIOSpecDefault { pattern == "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", canonical, nonCanonical, - binaryProperties.get("mimeType").one.flatMap(_.get("enum").one) == - Right(Json.Array(Json.String("image/png"))) + mimeType.flatMap(_.get("pattern").one) == + Right(Json.String("^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$")), + mimeType.flatMap(_.get("enum").one) == Right(Json.Array(Json.String("image/png"))) ) }, test("union export keeps discriminator and branch body while packing enforces the rule") { @@ -454,6 +456,21 @@ object SchemaRefSpec extends ZIOSpecDefault { val expected = Json.Number(BigDecimal((Int.MaxValue.toLong * 4 + 2) / 3)) assertTrue(bytes.get("minLength").one == Right(expected), bytes.get("maxLength").one == Right(expected)) }, + test("binary MIME syntax is canonical while MIME metadata stays optional") { + val ref = SchemaRef( + SchemaGraph( + ListMap.empty, + SchemaType(BinaryType(BinaryRestrictions(mimeTypes = Some(List("image/png"))))) + ) + ) + val withoutMime = Json.Object("bytes" -> Json.String("AQ")) + val invalidMime = Json.Object("bytes" -> Json.String("AQ"), "mimeType" -> Json.String("not a mime")) + assertTrue( + ref.validateJson(withoutMime).isRight, + ref.packJson(invalidMime).isLeft, + ref.unpackJson(BinaryValue(Vector[Byte](1), Some("not a mime"))).isLeft + ) + }, test("throwing config codecs return schema encode failures") { val definition = AgentClientDefinition.full[String, String]( name = "ConfiguredCounterAgent", diff --git a/test-data/reflection-conformance/v1.json b/test-data/reflection-conformance/v1.json index c184339bf2..83a27772d8 100644 --- a/test-data/reflection-conformance/v1.json +++ b/test-data/reflection-conformance/v1.json @@ -25,6 +25,8 @@ "canonical/quantity-wide-mantissa", "canonical/omitted-options", "canonical/binary-mime", + "canonical/constrained-text-without-language", + "canonical/constrained-binary-without-mime", "errors/s64-plus", "errors/s64-leading-zero", "errors/s64-negative-zero", @@ -36,6 +38,8 @@ "errors/quantity-negative-zero", "errors/quantity-extra-field", "errors/binary-noncanonical-base64", + "errors/binary-invalid-mime", + "errors/text-extra-field", "json-schema/s64", "json-schema/s64-restrictions", "json-schema/u32-restrictions", @@ -113,6 +117,20 @@ "input": { "bytes": "-_8", "mimeType": "application/octet-stream" }, "expected": { "bytes": "-_8", "mimeType": "application/octet-stream" } }, + { + "id": "canonical/constrained-text-without-language", + "operation": "roundtrip", + "fixture": "constrained-text", + "input": { "text": "hello" }, + "expected": { "text": "hello" } + }, + { + "id": "canonical/constrained-binary-without-mime", + "operation": "roundtrip", + "fixture": "constrained-binary", + "input": { "bytes": "AQI" }, + "expected": { "bytes": "AQI" } + }, { "id": "errors/s64-plus", "operation": "reject", @@ -196,6 +214,20 @@ ], "expected": { "kind": "invalid-json" } }, + { + "id": "errors/binary-invalid-mime", + "operation": "reject", + "fixture": "binary", + "input": { "bytes": "AQ", "mimeType": "not a mime" }, + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/text-extra-field", + "operation": "reject", + "fixture": "constrained-text", + "input": { "text": "hello", "extra": true }, + "expected": { "kind": "invalid-json" } + }, { "id": "json-schema/s64", "operation": "json-schema", @@ -297,6 +329,7 @@ }, "mimeType": { "type": "string", + "pattern": "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$", "enum": ["image/png", "application/octet-stream"] } }, From 512393d8503cdf272e6224091b103d9c5e5a27ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 25 Sep 2026 14:03:12 +0200 Subject: [PATCH 32/37] fix: close reflection review gaps --- .../runtime/golem/bridge/runtime/Bridge.scala | 15 ++++++++----- .../src/bridge_gen/typescript/mod.rs | 20 +++++++++++++++++ cli/golem-cli/tests/bridge_gen/scala.rs | 15 +++++++++++++ cli/golem-cli/tests/bridge_gen/typescript.rs | 18 +++++++++++++++ sdks/effect/src/SchemaRef.ts | 5 ++++- .../src/internal/reflection/schemaRender.ts | 7 ++++-- sdks/effect/test/schema-ref.test.ts | 21 ++++++++++++++++++ .../scala/golem/reflection/SchemaRef.scala | 11 ++++++---- .../golem/reflection/SchemaRefSpec.scala | 22 +++++++++++++++++-- .../packages/golem-ts-sdk/src/schema/ref.ts | 3 +++ .../golem-ts-sdk/src/schema/render.ts | 7 ++++-- .../golem-ts-sdk/tests/schema-ref.test.ts | 17 ++++++++++++-- 12 files changed, 142 insertions(+), 19 deletions(-) diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala index 6ed0ad180c..1bd4822a76 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala @@ -22,6 +22,7 @@ import java.net.URI import java.net.http.{HttpClient, HttpRequest, HttpResponse} import java.util.concurrent.CompletableFuture import scala.concurrent.{Future, Promise} +import scala.util.Try /** * The REST transport shared by all generated bridge clients in this project. @@ -52,9 +53,10 @@ object Bridge { phantomId = phantomId, config = config ) - val body = BridgeProtocol.encodeCreateAgentRequest(request).render - send(configuration, "create-agent", body, None).flatMap { response => - complete("create-agent", response, BridgeProtocol.decodeCreateAgentResponse) + Future.fromTry(Try(BridgeProtocol.encodeCreateAgentRequest(request).render)).flatMap { body => + send(configuration, "create-agent", body, None).flatMap { response => + complete("create-agent", response, BridgeProtocol.decodeCreateAgentResponse) + } } } @@ -81,9 +83,10 @@ object Bridge { scheduleAt = scheduleAt, idempotencyKey = None ) - val body = BridgeProtocol.encodeAgentInvocationRequest(request).render - send(configuration, "invoke-agent", body, request.idempotencyKey).flatMap { response => - complete("invoke-agent", response, BridgeProtocol.decodeAgentInvocationResult) + Future.fromTry(Try(BridgeProtocol.encodeAgentInvocationRequest(request).render)).flatMap { body => + send(configuration, "invoke-agent", body, request.idempotencyKey).flatMap { response => + complete("invoke-agent", response, BridgeProtocol.decodeAgentInvocationResult) + } } } diff --git a/cli/golem-cli/src/bridge_gen/typescript/mod.rs b/cli/golem-cli/src/bridge_gen/typescript/mod.rs index 7d7b3d53d2..71ab3c393d 100644 --- a/cli/golem-cli/src/bridge_gen/typescript/mod.rs +++ b/cli/golem-cli/src/bridge_gen/typescript/mod.rs @@ -1965,6 +1965,16 @@ impl TypeScriptBridgeGenerator { typ: &SchemaType, stream: &str, ) -> anyhow::Result { + if unstructured_text_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.tag === 'inline') return {{ $case: 'inline', value: {{ text: v.val, ...(v.languageCode !== undefined ? {{ language: v.languageCode }} : {{}}) }} }}; if(v.tag === 'url') return {{ $case: 'url', value: v.val }}; throw new Error('unknown unstructured text variant'); }})({value})" + )); + } + if unstructured_binary_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.tag === 'inline') return {{ $case: 'inline', value: {{ bytes: Buffer.from(v.val).toString('base64url'), ...(v.mimeType !== undefined ? {{ mimeType: v.mimeType }} : {{}}) }} }}; if(v.tag === 'url') return {{ $case: 'url', value: v.val }}; throw new Error('unknown unstructured binary variant'); }})({value})" + )); + } Ok(match typ { SchemaType::S64 { .. } | SchemaType::U64 { .. } => format!("({value}).toString()"), SchemaType::Bool { .. } @@ -2137,6 +2147,16 @@ impl TypeScriptBridgeGenerator { } fn encode_canonical_value_body(&self, value: &str, typ: &SchemaType) -> anyhow::Result { + if unstructured_text_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.tag === 'inline') return {{ inline: {{ text: v.val, ...(v.languageCode !== undefined ? {{ language: v.languageCode }} : {{}}) }} }}; if(v.tag === 'url') return {{ url: v.val }}; throw new Error('unknown unstructured text variant'); }})({value})" + )); + } + if unstructured_binary_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.tag === 'inline') return {{ inline: {{ bytes: Buffer.from(v.val).toString('base64url'), ...(v.mimeType !== undefined ? {{ mimeType: v.mimeType }} : {{}}) }} }}; if(v.tag === 'url') return {{ url: v.val }}; throw new Error('unknown unstructured binary variant'); }})({value})" + )); + } Ok(match typ { SchemaType::S64 { .. } | SchemaType::U64 { .. } => format!("({value}).toString()"), SchemaType::Bool { .. } diff --git a/cli/golem-cli/tests/bridge_gen/scala.rs b/cli/golem-cli/tests/bridge_gen/scala.rs index 505d636b5e..711e570c64 100644 --- a/cli/golem-cli/tests/bridge_gen/scala.rs +++ b/cli/golem-cli/tests/bridge_gen/scala.rs @@ -597,6 +597,21 @@ class StreamRuntimeTest extends munit.FunSuite { Vector(configExpected, Json.parse("""{"path":["optional"],"value":"west"}""").toOption.get) ) assertEquals(Json.requireField(config.head, "value").toOption.get, Json.string(Long.MaxValue.toString)) + + val floatCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"f64","value":{}}}""" + ) + val call = scala.util.Try( + Bridge.createAgent( + Configuration(GolemServer.Custom("http://127.0.0.1:1", "token"), "app", "env", global), + "ConfigAgent", + SchemaValue.TupleValue(List.empty), + None, + List(AgentConfigEntry(List("float"), SchemaValue.F64Value(Double.NaN), floatCodec)) + ) + ) + assert(call.isSuccess) + intercept[BridgeException](Await.result(call.get, 1.second)) } test("public codec enforces quantity bounds restrictions unions and integer boundaries") { diff --git a/cli/golem-cli/tests/bridge_gen/typescript.rs b/cli/golem-cli/tests/bridge_gen/typescript.rs index 133ef89850..bfb8216954 100644 --- a/cli/golem-cli/tests/bridge_gen/typescript.rs +++ b/cli/golem-cli/tests/bridge_gen/typescript.rs @@ -344,6 +344,14 @@ fn external_rest_config_uses_canonical_json_for_named_non_streaming_values() { }], }), ), + named_field( + "document", + unstructured_text_schema_type(TextRestrictions::default()), + ), + named_field( + "attachment", + unstructured_binary_schema_type(BinaryRestrictions::default()), + ), ]), )], AgentMode::Durable, @@ -376,6 +384,16 @@ fn external_rest_config_uses_canonical_json_for_named_non_streaming_values() { assert!(canonical.contains("'ok' in v ? { ok:")); assert!(canonical.contains("unknown variant")); assert!(canonical.contains("if(v.tag === \"command\") return")); + assert!(canonical.contains("if(v.tag === 'inline') return { inline: { text: v.val")); + assert!(canonical.contains( + "if(v.tag === 'inline') return { inline: { bytes: Buffer.from(v.val).toString('base64url')" + )); + assert!( + source.contains("if(v.tag === 'inline') return { $case: 'inline', value: { text: v.val") + ); + assert!(source.contains( + "if(v.tag === 'inline') return { $case: 'inline', value: { bytes: Buffer.from(v.val).toString('base64url')" + )); assert!(source.contains("void (")); install_and_build(&package_dir); } diff --git a/sdks/effect/src/SchemaRef.ts b/sdks/effect/src/SchemaRef.ts index 51bd33c871..661803ab38 100644 --- a/sdks/effect/src/SchemaRef.ts +++ b/sdks/effect/src/SchemaRef.ts @@ -50,7 +50,10 @@ export class SchemaRef { } /** Unpack a native schema value into canonical JSON. @since 1.6.0 @category conversions */ unpackJson(value: CoreTypes.SchemaValueTree): JsonValue { - return unpackJson(this.graph, this.root, schemaValueFromWit(value)) + const model = schemaValueFromWit(value) + if (!schemaValueConforms(this.graph, this.root, model)) + throw new SchemaRenderError([], "schema value does not conform to the expected schema") + return unpackJson(this.graph, this.root, model) } /** Explicitly validate canonical JSON. @since 1.6.0 @category validation */ validateJson(value: JsonValue): ValidationResult { diff --git a/sdks/effect/src/internal/reflection/schemaRender.ts b/sdks/effect/src/internal/reflection/schemaRender.ts index 0fab89c35e..df6546ed39 100644 --- a/sdks/effect/src/internal/reflection/schemaRender.ts +++ b/sdks/effect/src/internal/reflection/schemaRender.ts @@ -154,7 +154,7 @@ export function fromCanonicalJson( return { tag: "record", fields: body.fields.map((field) => { - if (!(field.name in object)) { + if (!Object.prototype.hasOwnProperty.call(object, field.name)) { if (resolve(graph, field.body).body.tag === "option") return { tag: "option" } fail([...path, field.name], "missing field") } @@ -315,11 +315,14 @@ export function toCanonicalJson( text: value.text, ...(value.language === undefined ? {} : { language: value.language }), } - case "binary": + case "binary": { + if (value.mimeType !== undefined && !MIME_TYPE_PATTERN.test(value.mimeType)) + fail([...path, "mimeType"], "invalid MIME type") return { bytes: bytesToBase64(value.bytes), ...(value.mimeType === undefined ? {} : { mimeType: value.mimeType }), } + } case "datetime": return datetimeToISOString(value.value) case "duration": diff --git a/sdks/effect/test/schema-ref.test.ts b/sdks/effect/test/schema-ref.test.ts index d0e7d149bc..929f067866 100644 --- a/sdks/effect/test/schema-ref.test.ts +++ b/sdks/effect/test/schema-ref.test.ts @@ -51,6 +51,9 @@ describe("SchemaRef", () => { }) expect(schema.validateJson({ bounded: 15 }).success).toBe(true) expect(schema.validateJson({ bounded: 15, nullable: null }).success).toBe(true) + expect( + ref(t.record([field("constructor", t.option(t.string()))])).validateJson({}).success, + ).toBe(true) for (const numeric of [t.f32, t.f64]) { expect( ref( @@ -63,6 +66,13 @@ describe("SchemaRef", () => { } }) + it("rejects native values outside declared restrictions before unpacking", () => { + const schema = ref(t.u32({ min: { tag: "unsigned", val: 10n } })) + expect(() => + schema.unpackJson({ root: 0, valueNodes: [{ tag: "u32-value", val: 1 }] }), + ).toThrow(/does not conform/) + }) + it("renders enforceable rich-value allowlists and canonical base64url bytes", () => { const rendered = ref( t.record([ @@ -120,6 +130,17 @@ describe("SchemaRef", () => { ).toBe(false) expect(schema.validateJson({ ...valid, duration: { nanoseconds: "-0" } }).success).toBe(false) expect(schema.unpackJson(schema.packJson(valid))).toEqual(valid) + expect(() => + ref(t.binary()).unpackJson({ + root: 0, + valueNodes: [ + { + tag: "binary-value", + val: { bytes: new Uint8Array([1]), mimeType: "not a mime" }, + }, + ], + }), + ).toThrow(/invalid MIME type/) }) it("validates nested native capabilities without consuming or rewriting them", () => { diff --git a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala index 1cbd6dad19..c68cf46732 100644 --- a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala @@ -129,9 +129,11 @@ private object CanonicalJson { ) ) } - def isOption(schema: SchemaType): Boolean = resolve(graph, schema).body match { - case OptionType(_) => true - case _ => false + def isOption(schema: SchemaType, seen: Set[String] = Set.empty): Boolean = schema.body match { + case OptionType(_) => true + case RefType(id) if seen(id) => false + case RefType(id) => graph.defs.get(id).exists(definition => isOption(definition.body, seen + id)) + case _ => false } schema.body match { case RefType(id) => Json.Object("$ref" -> Json.String(s"#/$$defs/${id.replace("~", "~0").replace("/", "~1")}")) @@ -233,7 +235,8 @@ private object CanonicalJson { restrictions.minBytes.map(value => "minLength" -> number(base64UrlLength(value))).toList ++ restrictions.maxBytes.map(value => "maxLength" -> number(base64UrlLength(value))).toList val mimeType = restrictions.mimeTypes match { - case Some(values) => typed( + case Some(values) => + typed( "string", "pattern" -> Json.String(MimeTypePattern.regex), "enum" -> Json.Array(values.map(Json.String): _*) diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala index 35e4bc756e..1d84123be0 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala @@ -218,11 +218,29 @@ object SchemaRefSpec extends ZIOSpecDefault { ) ) ) - val absent = RecordValue(List(OptionValue(None), OptionValue(None))) + val absent = RecordValue(List(OptionValue(None), OptionValue(None))) + val malformedRefs = SchemaRef( + SchemaGraph( + ListMap( + "cycle-a" -> SchemaTypeDef(SchemaType(RefType("cycle-b"))), + "cycle-b" -> SchemaTypeDef(SchemaType(RefType("cycle-a"))) + ), + SchemaType( + RecordType( + List( + NamedFieldType("dangling", SchemaType(RefType("missing"))), + NamedFieldType("cycle", SchemaType(RefType("cycle-a"))) + ) + ) + ) + ) + ) assertTrue( optional.packJson(Json.Object()) == Right(absent), optional.packJson(Json.Object("maybe" -> Json.Null, "referenced" -> Json.Null)) == Right(absent), - optional.toJsonSchema().get("required").one == Right(Json.Array()) + optional.toJsonSchema().get("required").one == Right(Json.Array()), + malformedRefs.toJsonSchema().get("required").one == + Right(Json.Array(Json.String("dangling"), Json.String("cycle"))) ) }, test("uses lossless canonical JSON for wide integers, durations, and quantities") { diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/ref.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/ref.ts index 32695cdfa5..762a3dd34d 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/ref.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/ref.ts @@ -84,6 +84,9 @@ export class SchemaRef { } unpackJson(value: SchemaValue): JsonValue { + if (!schemaValueConforms(this.graph, this.root, value)) { + throw new SchemaRenderError('schema value does not conform to the expected schema'); + } return toCanonicalJson(this.graph, this.root, value); } diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts index 6c38d65698..eb2997938d 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts @@ -101,7 +101,7 @@ export function fromCanonicalJson( return { tag: 'record', fields: body.fields.map((field) => { - if (!(field.name in object)) { + if (!Object.prototype.hasOwnProperty.call(object, field.name)) { if (resolve(graph, field.body).body.tag === 'option') return { tag: 'option' }; fail([...path, field.name], 'missing field'); } @@ -263,11 +263,14 @@ export function toCanonicalJson( text: value.text, ...(value.language === undefined ? {} : { language: value.language }), }; - case 'binary': + case 'binary': { + if (value.mimeType !== undefined && !MIME_TYPE_PATTERN.test(value.mimeType)) + fail([...path, 'mimeType'], 'invalid MIME type'); return { bytes: bytesToBase64(value.bytes), ...(value.mimeType === undefined ? {} : { mimeType: value.mimeType }), }; + } case 'datetime': return datetimeToISOString(value.value); case 'duration': diff --git a/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts index 662ef880f7..f9e75004a5 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts @@ -32,6 +32,9 @@ describe('SchemaRef canonical JSON', () => { const ref = schema(t.record([field('maybe', t.option(t.string()))])); expect(ref.packJson({})).toEqual(v.record([v.option()])); expect(ref.packJson({ maybe: null })).toEqual(v.record([v.option()])); + + const inheritedName = schema(t.record([field('constructor', t.option(t.string()))])); + expect(inheritedName.packJson({})).toEqual(v.record([v.option()])); }); it('rejects finite JSON numbers that overflow f32 after narrowing', () => { @@ -40,6 +43,11 @@ describe('SchemaRef canonical JSON', () => { expect(() => schema(t.f64()).unpackJson(v.f64(-Infinity))).toThrow(/finite JSON number/); }); + it('rejects native values outside declared restrictions before unpacking', () => { + const ref = schema(t.u32({ min: { tag: 'unsigned', val: 10n } })); + expect(() => ref.unpackJson(v.u32(1))).toThrow(/does not conform/); + }); + it('uses the canonical object representation for text', () => { const ref = schema( schemaType({ @@ -74,6 +82,11 @@ describe('SchemaRef canonical JSON', () => { issues: [{ path: ['bytes'], message: 'invalid base64url without padding' }], }); expect(ref.validateJson({ bytes: 'AQI', mimeType: 'not a mime' }).success).toBe(false); + expect(() => + schema(schemaType({ tag: 'binary', restrictions: {} })).unpackJson( + v.binary(Uint8Array.from([1]), 'not a mime'), + ), + ).toThrow(/invalid MIME type/); }); it('uses a canonical signed decimal nanosecond string for durations', () => { @@ -88,7 +101,7 @@ describe('SchemaRef canonical JSON', () => { }); it('uses canonical signed decimal strings for quantity mantissas', () => { - const ref = schema(t.quantity({ baseUnit: 'm', allowedUnits: [] })); + const ref = schema(t.quantity({ baseUnit: 'm', allowedSuffixes: [] })); const json = { mantissa: '123', scale: -2, unit: 'm' } as const; expect(ref.packJson(json)).toEqual(v.quantity({ mantissa: 123n, scale: -2, unit: 'm' })); @@ -173,7 +186,7 @@ describe('SchemaRef JSON Schema', () => { const root = t.record([ field('text', schemaType({ tag: 'text', restrictions: {} })), field('duration', t.duration()), - field('quantity', t.quantity({ baseUnit: 'm', allowedUnits: [] })), + field('quantity', t.quantity({ baseUnit: 'm', allowedSuffixes: [] })), ]); expect(schema(root).toJsonSchema()).toMatchObject({ From 828eb3a33704372d382f3b5334f33ef466be45e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 25 Sep 2026 14:49:11 +0200 Subject: [PATCH 33/37] fix: pass fingerprint in durable stream regression --- .../src/services/worker/session_index_tests.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/golem-worker-executor/src/services/worker/session_index_tests.rs b/golem-worker-executor/src/services/worker/session_index_tests.rs index 79aa70fc70..706b9a59ff 100644 --- a/golem-worker-executor/src/services/worker/session_index_tests.rs +++ b/golem-worker-executor/src/services/worker/session_index_tests.rs @@ -1526,7 +1526,12 @@ async fn forwarding_intent_reservation_is_indexed_in_both_owner_local_sessions() .await; oplog.commit(CommitLevel::Always).await; let from_source = service - .lookup_durable_stream_control_metadata(&owner, AgentMode::Durable, &source) + .lookup_durable_stream_control_metadata( + &owner, + AgentMode::Durable, + test_fingerprint(), + &source, + ) .await .unwrap(); assert_eq!( @@ -1556,7 +1561,12 @@ async fn forwarding_intent_reservation_is_indexed_in_both_owner_local_sessions() Arc::new(GolemConfig::default()), ); let persisted = reopened - .lookup_durable_stream_control_metadata(&owner, AgentMode::Durable, &destination) + .lookup_durable_stream_control_metadata( + &owner, + AgentMode::Durable, + test_fingerprint(), + &destination, + ) .await .unwrap(); assert_eq!(persisted, local); From 8815ec5ebc794021a7289033b7eca81979e4ebcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 25 Sep 2026 15:38:29 +0200 Subject: [PATCH 34/37] fix: close final bridge review gaps --- .../runtime/golem/bridge/runtime/Bridge.scala | 4 ++- .../src/bridge_gen/typescript/mod.rs | 10 +++++++ .../bridge_gen/effect_bridge_external.rs | 25 ++++++++++++++++++ cli/golem-cli/tests/bridge_gen/scala.rs | 26 +++++++++++++++++++ cli/golem-cli/tests/bridge_gen/typescript.rs | 18 +++++++++++++ .../packages/golem-ts-sdk/src/schema/union.ts | 5 +++- .../golem-ts-sdk/tests/schema-vendors.test.ts | 14 ++++++++++ 7 files changed, 100 insertions(+), 2 deletions(-) diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala index 1bd4822a76..ba73032845 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala @@ -149,5 +149,7 @@ object Bridge { inputCodec: PublicValueCodec.Codec, outputCodec: Option[PublicValueCodec.Codec] ): Future[AgentInvocationResult] = - StreamSession.invoke(resolved, methodName, methodParameters, constructorCodec, inputCodec, outputCodec) + Try( + StreamSession.invoke(resolved, methodName, methodParameters, constructorCodec, inputCodec, outputCodec) + ).fold(Future.failed, future => future) } diff --git a/cli/golem-cli/src/bridge_gen/typescript/mod.rs b/cli/golem-cli/src/bridge_gen/typescript/mod.rs index 71ab3c393d..123212690a 100644 --- a/cli/golem-cli/src/bridge_gen/typescript/mod.rs +++ b/cli/golem-cli/src/bridge_gen/typescript/mod.rs @@ -2308,6 +2308,16 @@ impl TypeScriptBridgeGenerator { } fn decode_public_value_body(&self, value: &str, typ: &SchemaType) -> anyhow::Result { + if unstructured_text_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.$case === 'inline') return base.UnstructuredText.fromInline(v.value.text, v.value.language); if(v.$case === 'url') return base.UnstructuredText.fromUrl(v.value); throw new Error('unknown unstructured text variant'); }})({value})" + )); + } + if unstructured_binary_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.$case === 'inline') return base.UnstructuredBinary.fromInline(Uint8Array.from(Buffer.from(v.value.bytes, 'base64url')), v.value.mimeType); if(v.$case === 'url') return base.UnstructuredBinary.fromUrl(v.value); throw new Error('unknown unstructured binary variant'); }})({value})" + )); + } Ok(match typ { SchemaType::S64 { .. } | SchemaType::U64 { .. } => format!("BigInt({value})"), SchemaType::Stream { diff --git a/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs b/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs index 7b86a5dacd..9de0ac7bfe 100644 --- a/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs +++ b/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs @@ -9,6 +9,10 @@ use golem_cli::bridge_gen::BridgeGenerator; use golem_cli::bridge_gen::effect::effect_external::EffectExternalBridgeGenerator; use golem_common::model::agent::AgentMode; use golem_common::schema::SchemaType; +use golem_common::schema::schema_type::{BinaryRestrictions, TextRestrictions}; +use golem_common::schema::unstructured::{ + unstructured_binary_schema_type, unstructured_text_schema_type, +}; use tempfile::TempDir; use test_r::test; @@ -25,6 +29,18 @@ fn effect_external_all_schema_streaming_consumer_compiles() { vec![field("payload", ref_to("MappedInput"))], None, )); + schema.methods.push(method( + "document", + vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], + Some(unstructured_text_schema_type(TextRestrictions::default())), + )); + schema.methods.push(method( + "attachment", + vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], + Some(unstructured_binary_schema_type( + BinaryRestrictions::default(), + )), + )); schema .methods .iter_mut() @@ -62,6 +78,15 @@ fn effect_external_all_schema_streaming_consumer_compiles() { !source.contains("Effect.Effect SchemaValue.TupleValue(List.empty), + boundedCodec, + tupleCodec, + None + ) + ) + assert(streamingCall.isSuccess) + intercept[BridgeException](Await.result(streamingCall.get, 1.second)) } test("public codec enforces quantity bounds restrictions unions and integer boundaries") { diff --git a/cli/golem-cli/tests/bridge_gen/typescript.rs b/cli/golem-cli/tests/bridge_gen/typescript.rs index bfb8216954..e8f85b00bc 100644 --- a/cli/golem-cli/tests/bridge_gen/typescript.rs +++ b/cli/golem-cli/tests/bridge_gen/typescript.rs @@ -1124,6 +1124,18 @@ fn external_streaming_generation_compiles_recursive_streams() { )))), ), method("status", vec![], Some(SchemaType::string())), + method( + "document", + vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], + Some(unstructured_text_schema_type(TextRestrictions::default())), + ), + method( + "attachment", + vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], + Some(unstructured_binary_schema_type( + BinaryRestrictions::default(), + )), + ), ], vec![], AgentMode::Durable, @@ -1158,6 +1170,12 @@ fn external_streaming_generation_compiles_recursive_streams() { assert!(source.contains("\"stable\"")); assert!(source.contains("config: this.publicConfig")); assert!(source.contains("path: [\"limits\",\"maximum\"]")); + assert!(source.contains( + "if(v.$case === 'inline') return base.UnstructuredText.fromInline(v.value.text, v.value.language)" + )); + assert!(source.contains( + "if(v.$case === 'inline') return base.UnstructuredBinary.fromInline(Uint8Array.from(Buffer.from(v.value.bytes, 'base64url')), v.value.mimeType)" + )); for line in source .lines() .filter(|line| line.contains(".push({ path: [\"limits\",\"maximum\"]")) diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/union.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/union.ts index c82cf3b354..1c37a2b4e8 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/union.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/union.ts @@ -130,7 +130,10 @@ export function matchesSchemaType( // distinct required keys. return ( isPlainObject(value) && - body.fields.every((f) => f.body.body.tag === 'option' || f.name in value) + body.fields.every( + (f) => + f.body.body.tag === 'option' || Object.prototype.hasOwnProperty.call(value, f.name), + ) ); case 'secret': case 'quota-token': diff --git a/sdks/ts/packages/golem-ts-sdk/tests/schema-vendors.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/schema-vendors.test.ts index 7649708b00..e14aafe501 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/schema-vendors.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/schema-vendors.test.ts @@ -23,12 +23,26 @@ import { type as arkType } from 'arktype'; import { Schema } from 'effect'; import { compileSchema } from '../src/schema/adapter'; +import { field, t } from '../src/internal/schema-model'; import '../src/schema/valibot'; import '../src/schema/arktype'; import '../src/schema/effect'; +import { matchesSchemaType } from '../src/schema/union'; const ark = (def: unknown) => arkType(def as never); +describe('plain-union structural matching', () => { + it('requires prototype-named record fields to be own properties', () => { + const constructorRecord = t.record([field('constructor', t.string())]); + const toStringRecord = t.record([field('toString', t.string())]); + + expect(matchesSchemaType(new Map(), constructorRecord, {})).toBe(false); + expect(matchesSchemaType(new Map(), toStringRecord, {})).toBe(false); + expect(matchesSchemaType(new Map(), constructorRecord, { constructor: 'own' })).toBe(true); + expect(matchesSchemaType(new Map(), toStringRecord, { toString: 'own' })).toBe(true); + }); +}); + // ============================================================ // Valibot (vendor '~standard'.vendor === 'valibot') // ============================================================ From 1e4047825500c176deb19a1180c8ca14faa93a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 25 Sep 2026 16:31:47 +0200 Subject: [PATCH 35/37] fix: preserve unstructured aliases in streaming clients --- cli/golem-cli/src/bridge_gen/typescript/mod.rs | 14 ++++++++++++++ .../tests/bridge_gen/effect_bridge_external.rs | 14 ++++++++++---- cli/golem-cli/tests/bridge_gen/typescript.rs | 17 ++++++++++++----- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/cli/golem-cli/src/bridge_gen/typescript/mod.rs b/cli/golem-cli/src/bridge_gen/typescript/mod.rs index 123212690a..0a2e13b245 100644 --- a/cli/golem-cli/src/bridge_gen/typescript/mod.rs +++ b/cli/golem-cli/src/bridge_gen/typescript/mod.rs @@ -4116,6 +4116,13 @@ impl TypeScriptBridgeGenerator { } fn streaming_type_definition(&self, typ: &SchemaType) -> anyhow::Result { + if let Some(restrictions) = unstructured_text_restrictions(self.type_naming.graph(), typ)? { + return Ok(self.unstructured_text_type(restrictions)); + } + if let Some(restrictions) = unstructured_binary_restrictions(self.type_naming.graph(), typ)? + { + return Ok(self.unstructured_binary_type(restrictions)); + } Ok(match self.resolve_ref(typ) { SchemaType::Variant { cases, .. } => cases .iter() @@ -4402,6 +4409,13 @@ impl TypeScriptBridgeGenerator { } fn type_definition(&self, typ: &SchemaType) -> anyhow::Result { + if let Some(restrictions) = unstructured_text_restrictions(self.type_naming.graph(), typ)? { + return Ok(self.unstructured_text_type(restrictions)); + } + if let Some(restrictions) = unstructured_binary_restrictions(self.type_naming.graph(), typ)? + { + return Ok(self.unstructured_binary_type(restrictions)); + } // Resolve through `Ref` so the body shape drives the type definition. let resolved = self.resolve_ref(typ); match resolved { diff --git a/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs b/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs index 9de0ac7bfe..8b31bf7f31 100644 --- a/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs +++ b/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs @@ -24,6 +24,14 @@ fn effect_external_all_schema_streaming_consumer_compiles() { "MappedInput", SchemaType::record(vec![named_field("item_stream", SchemaType::string())]), )); + schema.schema.defs.push(def( + "Document", + unstructured_text_schema_type(TextRestrictions::default()), + )); + schema.schema.defs.push(def( + "Attachment", + unstructured_binary_schema_type(BinaryRestrictions::default()), + )); schema.methods.push(method( "rename", vec![field("payload", ref_to("MappedInput"))], @@ -32,14 +40,12 @@ fn effect_external_all_schema_streaming_consumer_compiles() { schema.methods.push(method( "document", vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], - Some(unstructured_text_schema_type(TextRestrictions::default())), + Some(ref_to("Document")), )); schema.methods.push(method( "attachment", vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], - Some(unstructured_binary_schema_type( - BinaryRestrictions::default(), - )), + Some(ref_to("Attachment")), )); schema .methods diff --git a/cli/golem-cli/tests/bridge_gen/typescript.rs b/cli/golem-cli/tests/bridge_gen/typescript.rs index e8f85b00bc..dd6e6c9278 100644 --- a/cli/golem-cli/tests/bridge_gen/typescript.rs +++ b/cli/golem-cli/tests/bridge_gen/typescript.rs @@ -1127,17 +1127,24 @@ fn external_streaming_generation_compiles_recursive_streams() { method( "document", vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], - Some(unstructured_text_schema_type(TextRestrictions::default())), + Some(ref_to("Document")), ), method( "attachment", vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], - Some(unstructured_binary_schema_type( - BinaryRestrictions::default(), - )), + Some(ref_to("Attachment")), + ), + ], + vec![ + def( + "Document", + unstructured_text_schema_type(TextRestrictions::default()), + ), + def( + "Attachment", + unstructured_binary_schema_type(BinaryRestrictions::default()), ), ], - vec![], AgentMode::Durable, ); agent_type.config = vec![local_config( From 8b512f4a8bc0cdeca0b67a71cbed53bda0ae09d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 25 Sep 2026 19:56:21 +0200 Subject: [PATCH 36/37] test: tighten reflection conformance regressions --- golem-schema/src/schema/render/tests.rs | 184 +++++++++----- .../test/reflection-conformance.test.ts | 91 +++---- sdks/effect/test/schema-ref.test.ts | 7 + .../reflection_conformance_wbtest.mbt | 226 ++++++++++++------ .../rust/golem-rust/src/agentic/reflection.rs | 152 ++++++++---- .../ReflectionConformanceSpec.scala | 84 ++++--- .../golem/reflection/SchemaRefSpec.scala | 10 +- .../golem-ts-sdk/src/schema/render.ts | 5 +- .../tests/reflection-conformance.test.ts | 93 +++---- .../golem-ts-sdk/tests/schema-ref.test.ts | 95 ++++++-- .../golem-ts-sdk/tests/schema-union.test.ts | 29 +++ .../golem-ts-sdk/tests/schema-vendors.test.ts | 14 -- 12 files changed, 646 insertions(+), 344 deletions(-) create mode 100644 sdks/ts/packages/golem-ts-sdk/tests/schema-union.test.ts diff --git a/golem-schema/src/schema/render/tests.rs b/golem-schema/src/schema/render/tests.rs index 5280339f48..f6d3d476ce 100644 --- a/golem-schema/src/schema/render/tests.rs +++ b/golem-schema/src/schema/render/tests.rs @@ -364,37 +364,37 @@ fn numeric_json_schemas_apply_every_narrow_and_float_bound() { }; let cases = [ SchemaType::S8 { - restrictions: signed(-12, 12), + restrictions: signed(i64::MIN, i64::MAX), metadata: MetadataEnvelope::default(), }, SchemaType::S16 { - restrictions: signed(-1_200, 1_200), + restrictions: signed(i64::MIN, i64::MAX), metadata: MetadataEnvelope::default(), }, SchemaType::S32 { - restrictions: signed(-120_000, 120_000), + restrictions: signed(i64::MIN, i64::MAX), metadata: MetadataEnvelope::default(), }, SchemaType::U8 { - restrictions: unsigned(12, 120), + restrictions: unsigned(0, u64::MAX), metadata: MetadataEnvelope::default(), }, SchemaType::U16 { - restrictions: unsigned(1_200, 12_000), + restrictions: unsigned(0, u64::MAX), metadata: MetadataEnvelope::default(), }, SchemaType::U32 { - restrictions: unsigned(120_000, 1_200_000), + restrictions: unsigned(0, u64::MAX), metadata: MetadataEnvelope::default(), }, ]; let expected = [ - (-12, 12), - (-1_200, 1_200), - (-120_000, 120_000), - (12, 120), - (1_200, 12_000), - (120_000, 1_200_000), + (i8::MIN as i64, i8::MAX as i64), + (i16::MIN as i64, i16::MAX as i64), + (i32::MIN as i64, i32::MAX as i64), + (u8::MIN as i64, u8::MAX as i64), + (u16::MIN as i64, u16::MAX as i64), + (u32::MIN as i64, u32::MAX as i64), ]; for (ty, (min, max)) in cases.into_iter().zip(expected) { let schema = to_json_schema(&SchemaGraph::anonymous(ty.clone()), &ty); @@ -406,14 +406,14 @@ fn numeric_json_schemas_apply_every_narrow_and_float_bound() { ( SchemaType::F32 { restrictions: Some(NumericRestrictions { - min: Some(NumericBound::float(-1.5).unwrap()), - max: Some(NumericBound::float(2.5).unwrap()), + min: Some(NumericBound::float(-f64::MAX).unwrap()), + max: Some(NumericBound::float(f64::MAX).unwrap()), unit: None, }), metadata: MetadataEnvelope::default(), }, - -1.5, - 2.5, + -(f32::MAX as f64), + f32::MAX as f64, ), ( SchemaType::F64 { @@ -445,6 +445,7 @@ fn rich_json_schemas_enforce_allowlists_and_canonical_base64url() { text_schema["properties"]["language"]["enum"], json!(["en", "de"]) ); + assert!(text_schema.get("description").is_none()); let binary = SchemaType::binary(BinaryRestrictions { mime_types: Some(vec!["image/png".to_string()]), @@ -455,6 +456,7 @@ fn rich_json_schemas_enforce_allowlists_and_canonical_base64url() { binary_schema["properties"]["mimeType"]["enum"], json!(["image/png"]) ); + assert!(binary_schema.get("description").is_none()); let pattern = binary_schema["properties"]["bytes"]["pattern"] .as_str() .expect("binary bytes pattern"); @@ -489,7 +491,7 @@ fn malformed_json_and_schema_values_are_typed_errors() { assert!(matches!(mismatch, RenderError::ValueMismatch { .. })); } -fn conformance_fixture(name: &str) -> SchemaGraph { +fn conformance_fixture(name: &str, id: &str) -> SchemaGraph { let root = match name { "s64" => SchemaType::s64(), "constrained-s64" => SchemaType::S64 { @@ -573,7 +575,7 @@ fn conformance_fixture(name: &str) -> SchemaGraph { ]), }; } - other => panic!("unknown conformance fixture {other}"), + other => panic!("{id}: unknown conformance fixture {other}"), }; SchemaGraph::anonymous(root) } @@ -586,34 +588,34 @@ fn field(name: &str, body: SchemaType) -> NamedFieldType { } } -fn assert_json_subset(actual: &Value, expected: &Value) { +fn assert_json_subset(actual: &Value, expected: &Value, id: &str) { match expected { Value::Object(expected) => { let actual = actual .as_object() - .unwrap_or_else(|| panic!("expected object {expected:?}, got {actual}")); + .unwrap_or_else(|| panic!("{id}: expected object {expected:?}, got {actual}")); for (key, expected) in expected { let actual = actual .get(key) - .unwrap_or_else(|| panic!("missing key {key} in {actual:?}")); - assert_json_subset(actual, expected); + .unwrap_or_else(|| panic!("{id}: missing key {key} in {actual:?}")); + assert_json_subset(actual, expected, id); } } - _ => assert_eq!(actual, expected), + _ => assert_eq!(actual, expected, "{id}"), } } -fn json_pointer<'a>(value: &'a Value, pointer: &str) -> &'a Value { +fn json_pointer<'a>(value: &'a Value, pointer: &str, id: &str) -> &'a Value { if pointer.is_empty() { value } else { value .pointer(pointer) - .unwrap_or_else(|| panic!("missing JSON pointer {pointer} in {value}")) + .unwrap_or_else(|| panic!("{id}: missing JSON pointer {pointer} in {value}")) } } -fn assert_semantic_conformance(fixture: &str, expected: &Value) { +fn assert_semantic_conformance(fixture: &str, expected: &Value, id: &str) { match fixture { "unsupported-leaves" => { let types = [ @@ -623,12 +625,17 @@ fn assert_semantic_conformance(fixture: &str, expected: &Value) { SchemaType::future(None), SchemaType::stream(None), ]; - assert_eq!(types.len(), expected["count"].as_u64().unwrap() as usize); + assert_eq!( + types.len(), + expected["count"].as_u64().unwrap() as usize, + "{id}" + ); for ty in types { let graph = SchemaGraph::anonymous(ty.clone()); assert_json_subset( &to_reflection_json_schema(&graph, &ty, false), &expected["schema"], + id, ); } } @@ -672,7 +679,11 @@ fn assert_semantic_conformance(fixture: &str, expected: &Value) { "future", "stream", ]; - assert_eq!(serde_json::to_value(KINDS).unwrap(), expected["names"]); + assert_eq!( + serde_json::to_value(KINDS).unwrap(), + expected["names"], + "{id}" + ); } "all-restrictions" => { const RESTRICTIONS: &[&str] = &[ @@ -703,45 +714,51 @@ fn assert_semantic_conformance(fixture: &str, expected: &Value) { ]; assert_eq!( serde_json::to_value(RESTRICTIONS).unwrap(), - expected["names"] + expected["names"], + "{id}" ); } "graph" => { - let referenced = conformance_fixture("optional-record"); + let referenced = conformance_fixture("optional-record", id); let inline = SchemaGraph::anonymous(SchemaType::record(vec![ field("direct", SchemaType::option(SchemaType::string())), field("referenced", SchemaType::option(SchemaType::string())), ])); - assert!(validate_graph(&referenced).is_ok()); - assert!(is_equivalent_cross_graph( - &referenced, - &referenced.root, - &inline, - &inline.root, - )); + assert!(validate_graph(&referenced).is_ok(), "{id}"); + assert!( + is_equivalent_cross_graph(&referenced, &referenced.root, &inline, &inline.root,), + "{id}" + ); } - other => panic!("unknown semantic conformance fixture {other}"), + other => panic!("{id}: unknown semantic conformance fixture {other}"), } } -#[test] -fn reflection_conformance_corpus() { - let corpus: Value = serde_json::from_str(include_str!( +fn conformance_corpus() -> Value { + serde_json::from_str(include_str!( "../../../../test-data/reflection-conformance/v1.json" )) - .expect("valid reflection conformance corpus"); - assert_eq!(corpus["version"], "1.0.0"); + .expect("valid reflection conformance corpus") +} +fn run_conformance_cases(operation: &str) { + let corpus = conformance_corpus(); let cases = corpus["cases"].as_array().expect("cases array"); - let mut executed = HashSet::new(); for case in cases { let id = case["id"].as_str().expect("case id"); - assert!(executed.insert(id), "duplicate case ID {id}"); - let fixture = case["fixture"].as_str().expect("fixture"); + let case_operation = case["operation"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing operation")); + if case_operation != operation { + continue; + } + let fixture = case["fixture"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing fixture")); let expected = &case["expected"]; - match case["operation"].as_str().expect("operation") { + match operation { "roundtrip" => { - let graph = conformance_fixture(fixture); + let graph = conformance_fixture(fixture, id); let packed = from_json_value(&graph, &graph.root, &case["input"]) .unwrap_or_else(|error| panic!("{id}: {error}")); let rendered = to_json_value(&graph, &graph.root, &packed) @@ -749,7 +766,7 @@ fn reflection_conformance_corpus() { assert_eq!(&rendered, expected, "{id}"); } "reject" => { - let graph = conformance_fixture(fixture); + let graph = conformance_fixture(fixture, id); let inputs = case["inputs"] .as_array() .cloned() @@ -764,26 +781,75 @@ fn reflection_conformance_corpus() { }; assert_eq!( actual, - expected["kind"].as_str().expect("reject kind"), + expected["kind"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing reject kind")), "{id}" ); } } "json-schema" => { - let graph = conformance_fixture(fixture); + let graph = conformance_fixture(fixture, id); let rendered = to_reflection_json_schema(&graph, &graph.root, false); - let selected = json_pointer(&rendered, case["path"].as_str().expect("path")); - assert_json_subset(selected, expected); + let path = case["path"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing path")); + let selected = json_pointer(&rendered, path, id); + assert_json_subset(selected, expected, id); } - "semantic" => assert_semantic_conformance(fixture, expected), - operation => panic!("unknown conformance operation {operation} for {id}"), + "semantic" => assert_semantic_conformance(fixture, expected, id), + operation => panic!("unknown requested conformance operation {operation}"), } } - let declared: HashSet<_> = corpus["caseIds"] - .as_array() - .expect("declared case IDs") +} + +#[test] +fn reflection_conformance_corpus_integrity() { + let corpus = conformance_corpus(); + assert_eq!(corpus["version"], "1.0.0"); + let cases = corpus["cases"].as_array().expect("cases array"); + let recognized = HashSet::from(["roundtrip", "reject", "json-schema", "semantic"]); + let mut case_ids = HashSet::new(); + for case in cases { + let id = case["id"].as_str().expect("case id"); + assert!(case_ids.insert(id), "duplicate case ID {id}"); + let operation = case["operation"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing operation")); + assert!( + recognized.contains(operation), + "{id}: unknown operation {operation}" + ); + } + let declared_ids = corpus["caseIds"].as_array().expect("declared case IDs"); + let declared: HashSet<_> = declared_ids .iter() .map(|id| id.as_str().expect("declared case ID")) .collect(); - assert_eq!(executed, declared, "missing or unknown conformance cases"); + assert_eq!( + declared.len(), + declared_ids.len(), + "duplicate declared case ID" + ); + assert_eq!(case_ids, declared, "missing or unknown conformance cases"); +} + +#[test] +fn reflection_conformance_roundtrip_cases() { + run_conformance_cases("roundtrip"); +} + +#[test] +fn reflection_conformance_reject_cases() { + run_conformance_cases("reject"); +} + +#[test] +fn reflection_conformance_json_schema_cases() { + run_conformance_cases("json-schema"); +} + +#[test] +fn reflection_conformance_semantic_cases() { + run_conformance_cases("semantic"); } diff --git a/sdks/effect/test/reflection-conformance.test.ts b/sdks/effect/test/reflection-conformance.test.ts index 03d40ec2f6..547b20061b 100644 --- a/sdks/effect/test/reflection-conformance.test.ts +++ b/sdks/effect/test/reflection-conformance.test.ts @@ -249,54 +249,55 @@ function assertSemantic(testCase: ConformanceCase): void { } } +function executeCase(testCase: ConformanceCase): void { + switch (testCase.operation) { + case "roundtrip": { + const schema = fixture(testCase.fixture) + expect(schema.unpackJson(schema.packJson(testCase.input!))).toEqual(testCase.expected) + break + } + case "reject": { + for (const input of testCase.inputs ?? [testCase.input!]) { + const schema = fixture(testCase.fixture) + let packed: ReturnType + try { + packed = schema.packJson(input) + } catch { + expect("invalid-json").toBe((testCase.expected as { readonly kind: string }).kind) + continue + } + if (schema.validateValue(packed).success) + throw new Error(`accepted ${JSON.stringify(input)}`) + expect("constraint-violation").toBe((testCase.expected as { readonly kind: string }).kind) + } + break + } + case "json-schema": + expectSubset( + atPointer(fixture(testCase.fixture).toJsonSchema(), testCase.path ?? ""), + testCase.expected, + ) + break + case "semantic": + assertSemantic(testCase) + break + default: + throw new Error(`unknown conformance operation ${String(testCase.operation)}`) + } +} + describe("reflection conformance corpus", () => { - it("executes the complete declared case-ID set", () => { + it("has a valid version, unique declared case IDs, and recognized operations", () => { expect(corpus.version).toBe("1.0.0") - const executed = new Set() + const ids = corpus.cases.map((testCase) => testCase.id) + expect(new Set(ids).size, "duplicate corpus case ID").toBe(ids.length) + expect(new Set(corpus.caseIds).size, "duplicate declared case ID").toBe(corpus.caseIds.length) + expect([...ids].sort()).toEqual([...corpus.caseIds].sort()) + const operations = new Set(["roundtrip", "reject", "json-schema", "semantic"]) for (const testCase of corpus.cases) { - expect(executed.has(testCase.id), `duplicate case ID ${testCase.id}`).toBe(false) - executed.add(testCase.id) - switch (testCase.operation) { - case "roundtrip": { - const schema = fixture(testCase.fixture) - expect(schema.unpackJson(schema.packJson(testCase.input!)), testCase.id).toEqual( - testCase.expected, - ) - break - } - case "reject": { - for (const input of testCase.inputs ?? [testCase.input!]) { - const schema = fixture(testCase.fixture) - let packed: ReturnType - try { - packed = schema.packJson(input) - } catch { - expect("invalid-json", testCase.id).toBe( - (testCase.expected as { readonly kind: string }).kind, - ) - continue - } - if (schema.validateValue(packed).success) - throw new Error(`${testCase.id} accepted ${JSON.stringify(input)}`) - expect("constraint-violation", testCase.id).toBe( - (testCase.expected as { readonly kind: string }).kind, - ) - } - break - } - case "json-schema": - expectSubset( - atPointer(fixture(testCase.fixture).toJsonSchema(), testCase.path ?? ""), - testCase.expected, - ) - break - case "semantic": - assertSemantic(testCase) - break - default: - throw new Error(`unknown conformance operation for ${testCase.id}`) - } + expect(operations.has(testCase.operation), testCase.id).toBe(true) } - expect([...executed].sort()).toEqual([...corpus.caseIds].sort()) }) + + for (const testCase of corpus.cases) it(testCase.id, () => executeCase(testCase)) }) diff --git a/sdks/effect/test/schema-ref.test.ts b/sdks/effect/test/schema-ref.test.ts index 929f067866..37354332f5 100644 --- a/sdks/effect/test/schema-ref.test.ts +++ b/sdks/effect/test/schema-ref.test.ts @@ -86,6 +86,13 @@ describe("SchemaRef", () => { binary: { properties: { mimeType: { enum: ["image/png"] } } }, }, }) + const properties = ( + rendered as { + properties: { text: Record; binary: Record } + } + ).properties + expect(properties.text).not.toHaveProperty("description") + expect(properties.binary).not.toHaveProperty("description") const pattern = ( rendered as { properties: { binary: { properties: { bytes: { pattern: string } } } } diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt index ea9cc32240..c88ea57e5a 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt @@ -48,7 +48,7 @@ fn corpus_field( } ///| -fn conformance_fixture(name : String) -> SchemaRef raise { +fn conformance_fixture(name : String, id : String) -> SchemaRef raise { let root = match name { "s64" => @model.schema_type(S64(None)) "constrained-s64" => @@ -168,13 +168,17 @@ fn conformance_fixture(name : String) -> SchemaRef raise { ), }) } - other => fail("unknown conformance fixture " + other) + other => fail(id + ": unknown conformance fixture " + other) } SchemaRef::new({ defs: [], root, }) } ///| -fn conformance_at_pointer(value : Json, pointer : String) -> Json raise { +fn conformance_at_pointer( + value : Json, + pointer : String, + id : String, +) -> Json raise { match pointer { "" => value "/required" => corpus_object(value).get("required").unwrap() @@ -186,12 +190,16 @@ fn conformance_at_pointer(value : Json, pointer : String) -> Json raise { corpus_object(corpus_object(value).get("properties").unwrap()) .get("mantissa") .unwrap() - other => fail("unsupported conformance JSON pointer " + other) + other => fail(id + ": unsupported conformance JSON pointer " + other) } } ///| -fn assert_conformance_subset(actual : Json, expected : Json) -> Unit raise { +fn assert_conformance_subset( + actual : Json, + expected : Json, + id : String, +) -> Unit raise { match expected { Object(expected_fields) => { let actual_fields = corpus_object(actual) @@ -199,12 +207,14 @@ fn assert_conformance_subset(actual : Json, expected : Json) -> Unit raise { let (name, expected_value) = entry let actual_value = match actual_fields.get(name) { Some(value) => value - None => fail("missing conformance JSON field " + name) + None => fail(id + ": missing conformance JSON field " + name) } - assert_conformance_subset(actual_value, expected_value) + assert_conformance_subset(actual_value, expected_value, id) } } - _ => assert_eq(actual, expected) + _ => { + guard actual == expected else { fail(id + ": JSON values differ") } + } } } @@ -213,6 +223,7 @@ fn assert_conformance_semantic( name : String, expected : Json, corpus : Json, + id : String, ) -> Unit raise { match name { "unsupported-leaves" => { @@ -227,14 +238,15 @@ fn assert_conformance_semantic( @model.SchemaTypeBody::Stream(None), ] let expected_fields = corpus_object(expected) - assert_eq( - types.length(), - corpus_count(expected_fields.get("count").unwrap()), - ) + guard types.length() == + corpus_count(expected_fields.get("count").unwrap()) else { + fail(id + ": unsupported-leaf count differs") + } for body in types { assert_conformance_subset( test_schema(body).to_json_schema(include_draft_marker=false), expected_fields.get("schema").unwrap(), + id, ) } } @@ -250,13 +262,15 @@ fn assert_conformance_semantic( let expected_names = corpus_array( corpus_object(expected).get("names").unwrap(), ).map(corpus_string) - assert_eq(supported, expected_names) - assert_eq( - corpus_array(corpus_fields.get("schemaKinds").unwrap()).map( + guard supported == expected_names else { + fail(id + ": supported schema kinds differ") + } + guard corpus_array(corpus_fields.get("schemaKinds").unwrap()).map( corpus_string, - ), - expected_names, - ) + ) == + expected_names else { + fail(id + ": declared schema kinds differ") + } } "all-restrictions" => { let corpus_fields = corpus_object(corpus) @@ -270,17 +284,19 @@ fn assert_conformance_semantic( let expected_names = corpus_array( corpus_object(expected).get("names").unwrap(), ).map(corpus_string) - assert_eq(supported, expected_names) - assert_eq( - corpus_array(corpus_fields.get("restrictionKinds").unwrap()).map( + guard supported == expected_names else { + fail(id + ": supported restriction kinds differ") + } + guard corpus_array(corpus_fields.get("restrictionKinds").unwrap()).map( corpus_string, - ), - expected_names, - ) + ) == + expected_names else { + fail(id + ": declared restriction kinds differ") + } } "graph" => { let expected_fields = corpus_object(expected) - let referenced = conformance_fixture("optional-record") + let referenced = conformance_fixture("optional-record", id) let inline = test_schema( Record([ corpus_field( @@ -296,13 +312,15 @@ fn assert_conformance_semantic( let valid = @model.validate_graph(referenced.graph()).length() == 0 let equivalent = referenced.pack_json(Json::object({})) == inline.pack_json(Json::object({})) - assert_eq(valid, corpus_bool(expected_fields.get("valid").unwrap())) - assert_eq( - equivalent, - corpus_bool(expected_fields.get("equivalent").unwrap()), - ) + guard valid == corpus_bool(expected_fields.get("valid").unwrap()) else { + fail(id + ": graph validity differs") + } + guard equivalent == + corpus_bool(expected_fields.get("equivalent").unwrap()) else { + fail(id + ": graph equivalence differs") + } } - other => fail("unknown semantic conformance fixture " + other) + other => fail(id + ": unknown semantic conformance fixture " + other) } } @@ -329,66 +347,122 @@ fn conformance_reject_kind( } ///| -test "reflection conformance corpus executes the complete case-ID set" { +fn run_conformance_operation(operation : String) -> Unit raise { let corpus = @json.parse(reflection_conformance_corpus_json) let root = corpus_object(corpus) - assert_eq(corpus_string(root.get("version").unwrap()), "1.0.0") - let executed : Array[String] = [] for test_case_json in corpus_array(root.get("cases").unwrap()) { let test_case = corpus_object(test_case_json) let id = corpus_string(test_case.get("id").unwrap()) - guard !executed.any(value => value == id) else { - fail("duplicate conformance case ID " + id) - } - executed.push(id) - let name = corpus_string(test_case.get("fixture").unwrap()) - match corpus_string(test_case.get("operation").unwrap()) { - "roundtrip" => { - let schema = conformance_fixture(name) - let packed = schema.pack_json(test_case.get("input").unwrap()) - assert_eq( - schema.unpack_json(packed), - test_case.get("expected").unwrap(), - ) - } - "reject" => { - let schema = conformance_fixture(name) - let inputs = match test_case.get("inputs") { - Some(values) => corpus_array(values) - None => [test_case.get("input").unwrap()] + if corpus_string(test_case.get("operation").unwrap()) == operation { + let name = corpus_string(test_case.get("fixture").unwrap()) + match operation { + "roundtrip" => { + let schema = conformance_fixture(name, id) + let actual = schema.unpack_json( + schema.pack_json(test_case.get("input").unwrap()), + ) catch { + error => fail(id + ": " + repr(error)) + } + guard actual == test_case.get("expected").unwrap() else { + fail(id + ": roundtrip output differs") + } } - for input in inputs { - assert_eq( - conformance_reject_kind(schema, input, id), - corpus_string( + "reject" => { + let schema = conformance_fixture(name, id) + let inputs = match test_case.get("inputs") { + Some(values) => corpus_array(values) + None => [test_case.get("input").unwrap()] + } + for input in inputs { + let actual = conformance_reject_kind(schema, input, id) + let expected = corpus_string( corpus_object(test_case.get("expected").unwrap()) .get("kind") .unwrap(), + ) + guard actual == expected else { + fail(id + ": rejection category differs") + } + } + } + "json-schema" => + assert_conformance_subset( + conformance_at_pointer( + conformance_fixture(name, id).to_json_schema( + include_draft_marker=false, + ), + corpus_string(test_case.get("path").unwrap()), + id, ), + test_case.get("expected").unwrap(), + id, ) - } + "semantic" => + assert_conformance_semantic( + name, + test_case.get("expected").unwrap(), + corpus, + id, + ) + other => fail("unknown requested conformance operation " + other) } - "json-schema" => - assert_conformance_subset( - conformance_at_pointer( - conformance_fixture(name).to_json_schema(include_draft_marker=false), - corpus_string(test_case.get("path").unwrap()), - ), - test_case.get("expected").unwrap(), - ) - "semantic" => - assert_conformance_semantic( - name, - test_case.get("expected").unwrap(), - corpus, - ) - operation => - fail("unknown conformance operation " + operation + " for " + id) + } + } +} + +///| +test "reflection conformance corpus integrity" { + let corpus = @json.parse(reflection_conformance_corpus_json) + let root = corpus_object(corpus) + guard corpus_string(root.get("version").unwrap()) == "1.0.0" else { + fail("unexpected reflection conformance corpus version") + } + let executed : Array[String] = [] + let recognized = ["roundtrip", "reject", "json-schema", "semantic"] + for test_case_json in corpus_array(root.get("cases").unwrap()) { + let test_case = corpus_object(test_case_json) + let id = corpus_string(test_case.get("id").unwrap()) + guard !executed.any(value => value == id) else { + fail("duplicate conformance case ID " + id) + } + executed.push(id) + let operation = corpus_string(test_case.get("operation").unwrap()) + guard recognized.any(value => value == operation) else { + fail(id + ": unknown conformance operation " + operation) } } let declared = corpus_array(root.get("caseIds").unwrap()).map(corpus_string) - assert_eq(executed.length(), declared.length()) + guard executed.length() == declared.length() else { + fail("declared conformance case count differs") + } + let declared_seen : Array[String] = [] for id in declared { - assert_true(executed.any(value => value == id)) + guard !declared_seen.any(value => value == id) else { + fail("duplicate declared conformance case ID " + id) + } + declared_seen.push(id) + guard executed.any(value => value == id) else { + fail("undeclared conformance case ID " + id) + } } } + +///| +test "reflection conformance roundtrip cases" { + run_conformance_operation("roundtrip") +} + +///| +test "reflection conformance rejection cases" { + run_conformance_operation("reject") +} + +///| +test "reflection conformance JSON Schema cases" { + run_conformance_operation("json-schema") +} + +///| +test "reflection conformance semantic cases" { + run_conformance_operation("semantic") +} diff --git a/sdks/rust/golem-rust/src/agentic/reflection.rs b/sdks/rust/golem-rust/src/agentic/reflection.rs index 7bc401b0e7..d9fd1f0662 100644 --- a/sdks/rust/golem-rust/src/agentic/reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/reflection.rs @@ -1759,7 +1759,7 @@ mod tests { } } - fn conformance_schema(name: &str) -> SchemaRef { + fn conformance_schema(name: &str, id: &str) -> SchemaRef { let root = match name { "s64" => SchemaType::s64(), "constrained-s64" => SchemaType::S64 { @@ -1843,39 +1843,42 @@ mod tests { ]), }); } - other => panic!("unknown conformance fixture {other}"), + other => panic!("{id}: unknown conformance fixture {other}"), }; SchemaRef::new(SchemaGraph::anonymous(root)) } - fn conformance_json_pointer<'a>(value: &'a Value, pointer: &str) -> &'a Value { + fn conformance_json_pointer<'a>(value: &'a Value, pointer: &str, id: &str) -> &'a Value { if pointer.is_empty() { value } else { value .pointer(pointer) - .unwrap_or_else(|| panic!("missing JSON pointer {pointer} in {value}")) + .unwrap_or_else(|| panic!("{id}: missing JSON pointer {pointer} in {value}")) } } - fn assert_conformance_subset(actual: &Value, expected: &Value) { + fn assert_conformance_subset(actual: &Value, expected: &Value, id: &str) { match expected { Value::Object(expected) => { - let actual = actual.as_object().expect("actual JSON object"); + let actual = actual + .as_object() + .unwrap_or_else(|| panic!("{id}: expected JSON object, got {actual}")); for (key, expected) in expected { assert_conformance_subset( actual .get(key) - .unwrap_or_else(|| panic!("missing key {key} in {actual:?}")), + .unwrap_or_else(|| panic!("{id}: missing key {key} in {actual:?}")), expected, + id, ); } } - _ => assert_eq!(actual, expected), + _ => assert_eq!(actual, expected, "{id}"), } } - fn assert_conformance_semantic(fixture: &str, expected: &Value) { + fn assert_conformance_semantic(fixture: &str, expected: &Value, id: &str) { match fixture { "unsupported-leaves" => { let types = [ @@ -1885,11 +1888,16 @@ mod tests { SchemaType::future(None), SchemaType::stream(None), ]; - assert_eq!(types.len(), expected["count"].as_u64().unwrap() as usize); + assert_eq!( + types.len(), + expected["count"].as_u64().unwrap() as usize, + "{id}" + ); for ty in types { assert_conformance_subset( &SchemaRef::new(SchemaGraph::anonymous(ty)).to_json_schema(false), &expected["schema"], + id, ); } } @@ -1934,7 +1942,8 @@ mod tests { "stream", ]) .unwrap(), - expected["names"] + expected["names"], + "{id}" ), "all-restrictions" => assert_eq!( serde_json::to_value(vec![ @@ -1964,42 +1973,55 @@ mod tests { "union-field", ]) .unwrap(), - expected["names"] + expected["names"], + "{id}" ), "graph" => { - let referenced = conformance_schema("optional-record"); + let referenced = conformance_schema("optional-record", id); let inline = SchemaRef::new(SchemaGraph::anonymous(SchemaType::record(vec![ conformance_field("direct", SchemaType::option(SchemaType::string())), conformance_field("referenced", SchemaType::option(SchemaType::string())), ]))); - assert!(validate_graph(referenced.graph()).is_ok()); - assert!(is_equivalent_cross_graph( - referenced.graph(), - referenced.root(), - inline.graph(), - inline.root(), - )); + assert!(validate_graph(referenced.graph()).is_ok(), "{id}"); + assert!( + is_equivalent_cross_graph( + referenced.graph(), + referenced.root(), + inline.graph(), + inline.root(), + ), + "{id}" + ); } - other => panic!("unknown semantic conformance fixture {other}"), + other => panic!("{id}: unknown semantic conformance fixture {other}"), } } - #[test] - fn reflection_conformance_corpus() { - let corpus: Value = serde_json::from_str(include_str!( + fn conformance_corpus() -> Value { + serde_json::from_str(include_str!( "../../../../../test-data/reflection-conformance/v1.json" )) - .expect("valid reflection conformance corpus"); + .expect("valid reflection conformance corpus") + } + + fn run_conformance_cases(operation: &str) { + let corpus = conformance_corpus(); let cases = corpus["cases"].as_array().expect("cases array"); - let mut executed = HashSet::new(); for case in cases { let id = case["id"].as_str().expect("case id"); - assert!(executed.insert(id), "duplicate case ID {id}"); - let fixture = case["fixture"].as_str().expect("fixture"); + let case_operation = case["operation"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing operation")); + if case_operation != operation { + continue; + } + let fixture = case["fixture"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing fixture")); let expected = &case["expected"]; - match case["operation"].as_str().expect("operation") { + match operation { "roundtrip" => { - let schema = conformance_schema(fixture); + let schema = conformance_schema(fixture, id); let packed = schema .pack_json(&case["input"]) .unwrap_or_else(|error| panic!("{id}: {error}")); @@ -2012,7 +2034,7 @@ mod tests { ); } "reject" => { - let schema = conformance_schema(fixture); + let schema = conformance_schema(fixture, id); let inputs = case["inputs"] .as_array() .cloned() @@ -2030,30 +2052,80 @@ mod tests { }; assert_eq!( actual, - expected["kind"].as_str().expect("reject kind"), + expected["kind"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing reject kind")), "{id}" ); } } "json-schema" => { - let schema = conformance_schema(fixture); + let schema = conformance_schema(fixture, id); let rendered = schema.to_json_schema(false); + let path = case["path"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing path")); assert_conformance_subset( - conformance_json_pointer(&rendered, case["path"].as_str().expect("path")), + conformance_json_pointer(&rendered, path, id), expected, + id, ); } - "semantic" => assert_conformance_semantic(fixture, expected), - operation => panic!("unknown conformance operation {operation} for {id}"), + "semantic" => assert_conformance_semantic(fixture, expected, id), + operation => panic!("unknown requested conformance operation {operation}"), } } - let declared: HashSet<_> = corpus["caseIds"] - .as_array() - .expect("declared case IDs") + } + + #[test] + fn reflection_conformance_corpus_integrity() { + let corpus = conformance_corpus(); + assert_eq!(corpus["version"], "1.0.0"); + let cases = corpus["cases"].as_array().expect("cases array"); + let recognized = HashSet::from(["roundtrip", "reject", "json-schema", "semantic"]); + let mut case_ids = HashSet::new(); + for case in cases { + let id = case["id"].as_str().expect("case id"); + assert!(case_ids.insert(id), "duplicate case ID {id}"); + let operation = case["operation"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing operation")); + assert!( + recognized.contains(operation), + "{id}: unknown operation {operation}" + ); + } + let declared_ids = corpus["caseIds"].as_array().expect("declared case IDs"); + let declared: HashSet<_> = declared_ids .iter() .map(|id| id.as_str().expect("declared case ID")) .collect(); - assert_eq!(executed, declared, "missing or unknown conformance cases"); + assert_eq!( + declared.len(), + declared_ids.len(), + "duplicate declared case ID" + ); + assert_eq!(case_ids, declared, "missing or unknown conformance cases"); + } + + #[test] + fn reflection_conformance_roundtrip_cases() { + run_conformance_cases("roundtrip"); + } + + #[test] + fn reflection_conformance_reject_cases() { + run_conformance_cases("reject"); + } + + #[test] + fn reflection_conformance_json_schema_cases() { + run_conformance_cases("json-schema"); + } + + #[test] + fn reflection_conformance_semantic_cases() { + run_conformance_cases("semantic"); } #[test] diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala index 515a144c40..6d4440ac34 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala @@ -17,7 +17,6 @@ import zio.blocks.schema.json.Json import zio.test.* import scala.collection.immutable.ListMap -import scala.collection.mutable object ReflectionConformanceSpec extends ZIOSpecDefault { private def fields(value: Json): ListMap[String, Json] = value match { @@ -302,45 +301,56 @@ object ReflectionConformanceSpec extends ZIOSpecDefault { case other => throw new AssertionError(s"unknown semantic conformance fixture $other") } - override def spec = suite("reflection conformance corpus")( - test("executes the complete declared case-ID set") { - val corpus = Json.parse(ReflectionConformanceCorpus.json).fold(throw _, identity) - val root = fields(corpus) - Predef.assert(string(root("version")) == "1.0.0") - val executed = mutable.Set.empty[String] - elements(root("cases")).foreach { testCaseJson => - val testCase = fields(testCaseJson) - val id = string(testCase("id")) - Predef.assert(executed.add(id), s"duplicate case ID $id") - val name = string(testCase("fixture")) - string(testCase("operation")) match { - case "roundtrip" => + private val corpus = Json.parse(ReflectionConformanceCorpus.json).fold(throw _, identity) + private val corpusRoot = fields(corpus) + private val corpusCases = elements(corpusRoot("cases")) + + private def conformanceTest(testCaseJson: Json) = { + val testCase = fields(testCaseJson) + val id = string(testCase("id")) + test(id) { + val name = string(testCase("fixture")) + string(testCase("operation")) match { + case "roundtrip" => + val schema = fixture(name) + val packed = + schema.packJson(testCase("input")).fold(error => throw new AssertionError(s"$id: $error"), identity) + Predef.assert(schema.unpackJson(packed) == Right(testCase("expected")), id) + case "reject" => + val inputs = testCase.get("inputs").map(elements).getOrElse(List(testCase("input"))) + inputs.foreach { input => val schema = fixture(name) - val packed = - schema.packJson(testCase("input")).fold(error => throw new AssertionError(s"$id: $error"), identity) - Predef.assert(schema.unpackJson(packed) == Right(testCase("expected")), id) - case "reject" => - val inputs = testCase.get("inputs").map(elements).getOrElse(List(testCase("input"))) - inputs.foreach { input => - val schema = fixture(name) - val actual = schema.packJson(input) match { - case Left(_) => "invalid-json" - case Right(value) if schema.validateValue(value).isLeft => "constraint-violation" - case Right(value) => throw new AssertionError(s"$id accepted $input as $value") - } - Predef.assert(actual == string(fields(testCase("expected"))("kind")), id) + val actual = schema.packJson(input) match { + case Left(_) => "invalid-json" + case Right(value) if schema.validateValue(value).isLeft => "constraint-violation" + case Right(value) => throw new AssertionError(s"$id accepted $input as $value") } - case "json-schema" => - assertSubset( - atPointer(fixture(name).toJsonSchema(false), string(testCase("path"))), - testCase("expected") - ) - case "semantic" => assertSemantic(name, testCase("expected"), corpus) - case operation => throw new AssertionError(s"unknown conformance operation $operation for $id") - } + Predef.assert(actual == string(fields(testCase("expected"))("kind")), id) + } + case "json-schema" => + assertSubset( + atPointer(fixture(name).toJsonSchema(false), string(testCase("path"))), + testCase("expected") + ) + case "semantic" => assertSemantic(name, testCase("expected"), corpus) + case operation => throw new AssertionError(s"unknown conformance operation $operation for $id") } - val declared = elements(root("caseIds")).map(string).toSet - assertTrue(executed.toSet == declared) + assertTrue(true) } + } + + override def spec = suite("reflection conformance corpus")( + (test("has a valid version, unique declared case IDs, and recognized operations") { + val ids = corpusCases.map(testCase => string(fields(testCase)("id"))) + val declared = elements(corpusRoot("caseIds")).map(string) + val operations = Set("roundtrip", "reject", "json-schema", "semantic") + assertTrue( + string(corpusRoot("version")) == "1.0.0", + ids.distinct.size == ids.size, + declared.distinct.size == declared.size, + ids.toSet == declared.toSet, + corpusCases.forall(testCase => operations.contains(string(fields(testCase)("operation")))) + ) + } :: corpusCases.map(conformanceTest))* ) } diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala index 1d84123be0..b4899ae4e4 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala @@ -143,9 +143,11 @@ object SchemaRefSpec extends ZIOSpecDefault { val rendered = restricted.toJsonSchema() val properties = rendered.get("properties").one.toOption.get val count = properties.get("count").one.toOption.get - val textProperties = properties.get("message").one.toOption.get.get("properties").one.toOption.get + val textSchema = properties.get("message").one.toOption.get + val textProperties = textSchema.get("properties").one.toOption.get val text = textProperties.get("text").one - val binaryProperties = properties.get("content").one.toOption.get.get("properties").one.toOption.get + val binarySchema = properties.get("content").one.toOption.get + val binaryProperties = binarySchema.get("properties").one.toOption.get val bytes = binaryProperties.get("bytes").one val mimeType = binaryProperties.get("mimeType").one val pattern = bytes.flatMap(_.get("pattern").one) match { @@ -160,6 +162,7 @@ object SchemaRefSpec extends ZIOSpecDefault { text.flatMap(_.get("pattern").one) == Right(Json.String("^https://")), textProperties.get("language").one.flatMap(_.get("enum").one) == Right(Json.Array(Json.String("en"), Json.String("de"))), + textSchema.get("description").one.isLeft, bytes.flatMap(_.get("minLength").one) == Right(Json.Number(BigDecimal(4))), bytes.flatMap(_.get("maxLength").one) == Right(Json.Number(BigDecimal(8))), pattern == "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", @@ -167,7 +170,8 @@ object SchemaRefSpec extends ZIOSpecDefault { nonCanonical, mimeType.flatMap(_.get("pattern").one) == Right(Json.String("^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$")), - mimeType.flatMap(_.get("enum").one) == Right(Json.Array(Json.String("image/png"))) + mimeType.flatMap(_.get("enum").one) == Right(Json.Array(Json.String("image/png"))), + binarySchema.get("description").one.isLeft ) }, test("union export keeps discriminator and branch body while packing enforces the rule") { diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts index eb2997938d..692d2979cb 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts @@ -698,12 +698,12 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record { - it('executes the complete declared case-ID set', () => { - expect(corpus.version).toBe('1.0.0'); - const executed = new Set(); - for (const testCase of corpus.cases) { - expect(executed.has(testCase.id), `duplicate case ID ${testCase.id}`).toBe(false); - executed.add(testCase.id); - switch (testCase.operation) { - case 'roundtrip': { - const ref = fixture(testCase.fixture); - expect(ref.unpackJson(ref.packJson(testCase.input!)), testCase.id).toEqual( - testCase.expected, - ); - break; +function executeCase(testCase: ConformanceCase): void { + switch (testCase.operation) { + case 'roundtrip': { + const ref = fixture(testCase.fixture); + expect(ref.unpackJson(ref.packJson(testCase.input!))).toEqual(testCase.expected); + break; + } + case 'reject': { + const ref = fixture(testCase.fixture); + for (const input of testCase.inputs ?? [testCase.input!]) { + let packed: ReturnType; + try { + packed = ref.packJson(input); + } catch { + expect('invalid-json').toBe((testCase.expected as { readonly kind: string }).kind); + continue; } - case 'reject': { - const ref = fixture(testCase.fixture); - for (const input of testCase.inputs ?? [testCase.input!]) { - let packed: ReturnType; - try { - packed = ref.packJson(input); - } catch { - expect('invalid-json', testCase.id).toBe( - (testCase.expected as { readonly kind: string }).kind, - ); - continue; - } - if (ref.validateValue(packed).success) { - throw new Error(`${testCase.id} accepted ${JSON.stringify(input)}`); - } - expect('constraint-violation', testCase.id).toBe( - (testCase.expected as { readonly kind: string }).kind, - ); - } - break; + if (ref.validateValue(packed).success) { + throw new Error(`accepted ${JSON.stringify(input)}`); } - case 'json-schema': - expectSubset( - atPointer(fixture(testCase.fixture).toJsonSchema(), testCase.path ?? ''), - testCase.expected, - ); - break; - case 'semantic': - assertSemantic(testCase); - break; - default: - throw new Error(`unknown conformance operation for ${testCase.id}`); + expect('constraint-violation').toBe((testCase.expected as { readonly kind: string }).kind); } + break; + } + case 'json-schema': + expectSubset( + atPointer(fixture(testCase.fixture).toJsonSchema(), testCase.path ?? ''), + testCase.expected, + ); + break; + case 'semantic': + assertSemantic(testCase); + break; + default: + throw new Error(`unknown conformance operation ${String(testCase.operation)}`); + } +} + +describe('reflection conformance corpus', () => { + it('has a valid version, unique declared case IDs, and recognized operations', () => { + expect(corpus.version).toBe('1.0.0'); + const ids = corpus.cases.map((testCase) => testCase.id); + expect(new Set(ids).size, 'duplicate corpus case ID').toBe(ids.length); + expect(new Set(corpus.caseIds).size, 'duplicate declared case ID').toBe(corpus.caseIds.length); + expect([...ids].sort()).toEqual([...corpus.caseIds].sort()); + const operations = new Set(['roundtrip', 'reject', 'json-schema', 'semantic']); + for (const testCase of corpus.cases) { + expect(operations.has(testCase.operation), testCase.id).toBe(true); } - expect([...executed].sort()).toEqual([...corpus.caseIds].sort()); }); + + for (const testCase of corpus.cases) it(testCase.id, () => executeCase(testCase)); }); diff --git a/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts index f9e75004a5..3445ea9da9 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts @@ -27,6 +27,12 @@ function schema(root: SchemaGraph['root']): SchemaRef { return new SchemaRef({ defs: new Map(), root }); } +function floatBits(value: number): bigint { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value); + return view.getBigUint64(0); +} + describe('SchemaRef canonical JSON', () => { it('decodes omitted and explicit-null option fields as absent', () => { const ref = schema(t.record([field('maybe', t.option(t.string()))])); @@ -241,32 +247,70 @@ describe('SchemaRef JSON Schema', () => { }); it('renders declared bounds for every narrow integer and float family', () => { - for (const type of [ - t.s8({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), - t.s16({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), - t.s32({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), - t.u8({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), - t.u16({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), - t.u32({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), - ]) { + for (const [type, minimum, maximum] of [ + [ + t.s8({ + min: { tag: 'signed', val: -(2n ** 63n) }, + max: { tag: 'signed', val: 2n ** 63n - 1n }, + }), + -128, + 127, + ], + [ + t.s16({ + min: { tag: 'signed', val: -(2n ** 63n) }, + max: { tag: 'signed', val: 2n ** 63n - 1n }, + }), + -32768, + 32767, + ], + [ + t.s32({ + min: { tag: 'signed', val: -(2n ** 63n) }, + max: { tag: 'signed', val: 2n ** 63n - 1n }, + }), + -(2 ** 31), + 2 ** 31 - 1, + ], + [ + t.u8({ min: { tag: 'unsigned', val: 0n }, max: { tag: 'unsigned', val: 2n ** 64n - 1n } }), + 0, + 255, + ], + [ + t.u16({ min: { tag: 'unsigned', val: 0n }, max: { tag: 'unsigned', val: 2n ** 64n - 1n } }), + 0, + 65535, + ], + [ + t.u32({ min: { tag: 'unsigned', val: 0n }, max: { tag: 'unsigned', val: 2n ** 64n - 1n } }), + 0, + 2 ** 32 - 1, + ], + ] as const) { expect(schema(type).toJsonSchema()).toMatchObject({ - minimum: type.body.tag.startsWith('s') ? -12 : 2, - maximum: 12, + minimum, + maximum, }); } - for (const type of [ - t.f32({ - min: { tag: 'float-bits', val: 0xbff0000000000000n }, - max: { tag: 'float-bits', val: 0x3fe0000000000000n }, - }), - t.f64({ - min: { tag: 'float-bits', val: 0xbff0000000000000n }, - max: { tag: 'float-bits', val: 0x3fe0000000000000n }, - }), - ]) { - expect(schema(type).toJsonSchema()).toMatchObject({ minimum: -1, maximum: 0.5 }); - } + expect( + schema( + t.f32({ + min: { tag: 'float-bits', val: floatBits(-1e100) }, + max: { tag: 'float-bits', val: floatBits(1e100) }, + }), + ).toJsonSchema(), + ).toMatchObject({ minimum: -3.4028234663852886e38, maximum: 3.4028234663852886e38 }); + + expect( + schema( + t.f64({ + min: { tag: 'float-bits', val: 0xbff0000000000000n }, + max: { tag: 'float-bits', val: 0x3fe0000000000000n }, + }), + ).toJsonSchema(), + ).toMatchObject({ minimum: -1, maximum: 0.5 }); }); it('renders enforceable rich-value allowlists and canonical base64url bytes', () => { @@ -282,6 +326,13 @@ describe('SchemaRef JSON Schema', () => { binary: { properties: { mimeType: { enum: ['image/png'] } } }, }, }); + const properties = ( + rendered as { + properties: { text: Record; binary: Record }; + } + ).properties; + expect(properties.text).not.toHaveProperty('description'); + expect(properties.binary).not.toHaveProperty('description'); const pattern = ( rendered as { properties: { binary: { properties: { bytes: { pattern: string } } } }; diff --git a/sdks/ts/packages/golem-ts-sdk/tests/schema-union.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/schema-union.test.ts new file mode 100644 index 0000000000..b8f97e13dd --- /dev/null +++ b/sdks/ts/packages/golem-ts-sdk/tests/schema-union.test.ts @@ -0,0 +1,29 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, expect, it } from 'vitest'; +import { field, t } from '../src/internal/schema-model'; +import { matchesSchemaType } from '../src/schema/union'; + +describe('plain-union structural matching', () => { + it('requires prototype-named record fields to be own properties', () => { + const constructorRecord = t.record([field('constructor', t.string())]); + const toStringRecord = t.record([field('toString', t.string())]); + + expect(matchesSchemaType(new Map(), constructorRecord, {})).toBe(false); + expect(matchesSchemaType(new Map(), toStringRecord, {})).toBe(false); + expect(matchesSchemaType(new Map(), constructorRecord, { constructor: 'own' })).toBe(true); + expect(matchesSchemaType(new Map(), toStringRecord, { toString: 'own' })).toBe(true); + }); +}); diff --git a/sdks/ts/packages/golem-ts-sdk/tests/schema-vendors.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/schema-vendors.test.ts index e14aafe501..7649708b00 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/schema-vendors.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/schema-vendors.test.ts @@ -23,26 +23,12 @@ import { type as arkType } from 'arktype'; import { Schema } from 'effect'; import { compileSchema } from '../src/schema/adapter'; -import { field, t } from '../src/internal/schema-model'; import '../src/schema/valibot'; import '../src/schema/arktype'; import '../src/schema/effect'; -import { matchesSchemaType } from '../src/schema/union'; const ark = (def: unknown) => arkType(def as never); -describe('plain-union structural matching', () => { - it('requires prototype-named record fields to be own properties', () => { - const constructorRecord = t.record([field('constructor', t.string())]); - const toStringRecord = t.record([field('toString', t.string())]); - - expect(matchesSchemaType(new Map(), constructorRecord, {})).toBe(false); - expect(matchesSchemaType(new Map(), toStringRecord, {})).toBe(false); - expect(matchesSchemaType(new Map(), constructorRecord, { constructor: 'own' })).toBe(true); - expect(matchesSchemaType(new Map(), toStringRecord, { toString: 'own' })).toBe(true); - }); -}); - // ============================================================ // Valibot (vendor '~standard'.vendor === 'valibot') // ============================================================ From f1c84c655b5e245715a792283be8142dad247630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 25 Sep 2026 21:01:50 +0200 Subject: [PATCH 37/37] test: preserve reflection regression diagnostics --- golem-schema/src/schema/render/tests.rs | 106 +++++++-- .../reflection_conformance_wbtest.mbt | 220 ++++++++++++++---- .../rust/golem-rust/src/agentic/reflection.rs | 5 +- .../golem-ts-sdk/tests/schema-ref.test.ts | 43 ++-- 4 files changed, 295 insertions(+), 79 deletions(-) diff --git a/golem-schema/src/schema/render/tests.rs b/golem-schema/src/schema/render/tests.rs index f6d3d476ce..0e1dce46a2 100644 --- a/golem-schema/src/schema/render/tests.rs +++ b/golem-schema/src/schema/render/tests.rs @@ -364,37 +364,37 @@ fn numeric_json_schemas_apply_every_narrow_and_float_bound() { }; let cases = [ SchemaType::S8 { - restrictions: signed(i64::MIN, i64::MAX), + restrictions: signed(-12, 12), metadata: MetadataEnvelope::default(), }, SchemaType::S16 { - restrictions: signed(i64::MIN, i64::MAX), + restrictions: signed(-1_200, 1_200), metadata: MetadataEnvelope::default(), }, SchemaType::S32 { - restrictions: signed(i64::MIN, i64::MAX), + restrictions: signed(-120_000, 120_000), metadata: MetadataEnvelope::default(), }, SchemaType::U8 { - restrictions: unsigned(0, u64::MAX), + restrictions: unsigned(12, 120), metadata: MetadataEnvelope::default(), }, SchemaType::U16 { - restrictions: unsigned(0, u64::MAX), + restrictions: unsigned(1_200, 12_000), metadata: MetadataEnvelope::default(), }, SchemaType::U32 { - restrictions: unsigned(0, u64::MAX), + restrictions: unsigned(120_000, 1_200_000), metadata: MetadataEnvelope::default(), }, ]; let expected = [ - (i8::MIN as i64, i8::MAX as i64), - (i16::MIN as i64, i16::MAX as i64), - (i32::MIN as i64, i32::MAX as i64), - (u8::MIN as i64, u8::MAX as i64), - (u16::MIN as i64, u16::MAX as i64), - (u32::MIN as i64, u32::MAX as i64), + (-12, 12), + (-1_200, 1_200), + (-120_000, 120_000), + (12, 120), + (1_200, 12_000), + (120_000, 1_200_000), ]; for (ty, (min, max)) in cases.into_iter().zip(expected) { let schema = to_json_schema(&SchemaGraph::anonymous(ty.clone()), &ty); @@ -406,14 +406,14 @@ fn numeric_json_schemas_apply_every_narrow_and_float_bound() { ( SchemaType::F32 { restrictions: Some(NumericRestrictions { - min: Some(NumericBound::float(-f64::MAX).unwrap()), - max: Some(NumericBound::float(f64::MAX).unwrap()), + min: Some(NumericBound::float(-1.5).unwrap()), + max: Some(NumericBound::float(2.5).unwrap()), unit: None, }), metadata: MetadataEnvelope::default(), }, - -(f32::MAX as f64), - f32::MAX as f64, + -1.5, + 2.5, ), ( SchemaType::F64 { @@ -434,6 +434,75 @@ fn numeric_json_schemas_apply_every_narrow_and_float_bound() { } } +#[test] +fn numeric_json_schemas_clamp_out_of_domain_bounds_to_primitive_ranges() { + let signed = |min, max| { + Some(NumericRestrictions { + min: Some(NumericBound::Signed(min)), + max: Some(NumericBound::Signed(max)), + unit: None, + }) + }; + let unsigned = |min, max| { + Some(NumericRestrictions { + min: Some(NumericBound::Unsigned(min)), + max: Some(NumericBound::Unsigned(max)), + unit: None, + }) + }; + let cases = [ + SchemaType::S8 { + restrictions: signed(i64::MIN, i64::MAX), + metadata: MetadataEnvelope::default(), + }, + SchemaType::S16 { + restrictions: signed(i64::MIN, i64::MAX), + metadata: MetadataEnvelope::default(), + }, + SchemaType::S32 { + restrictions: signed(i64::MIN, i64::MAX), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U8 { + restrictions: unsigned(0, u64::MAX), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U16 { + restrictions: unsigned(0, u64::MAX), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U32 { + restrictions: unsigned(0, u64::MAX), + metadata: MetadataEnvelope::default(), + }, + ]; + let expected = [ + (i8::MIN as i64, i8::MAX as i64), + (i16::MIN as i64, i16::MAX as i64), + (i32::MIN as i64, i32::MAX as i64), + (u8::MIN as i64, u8::MAX as i64), + (u16::MIN as i64, u16::MAX as i64), + (u32::MIN as i64, u32::MAX as i64), + ]; + for (ty, (min, max)) in cases.into_iter().zip(expected) { + let schema = to_json_schema(&SchemaGraph::anonymous(ty.clone()), &ty); + assert_eq!(schema["minimum"], min); + assert_eq!(schema["maximum"], max); + } + + let ty = SchemaType::F32 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::float(-f64::MAX).unwrap()), + max: Some(NumericBound::float(f64::MAX).unwrap()), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }; + let schema = to_json_schema(&SchemaGraph::anonymous(ty.clone()), &ty); + assert_eq!(schema["minimum"], -(f32::MAX as f64)); + assert_eq!(schema["maximum"], f32::MAX as f64); +} + #[test] fn rich_json_schemas_enforce_allowlists_and_canonical_base64url() { let text = SchemaType::text(TextRestrictions { @@ -627,7 +696,10 @@ fn assert_semantic_conformance(fixture: &str, expected: &Value, id: &str) { ]; assert_eq!( types.len(), - expected["count"].as_u64().unwrap() as usize, + expected["count"] + .as_u64() + .unwrap_or_else(|| panic!("{id}: missing unsupported-leaves count")) + as usize, "{id}" ); for ty in types { diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt index c88ea57e5a..853a3b5ff7 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt @@ -23,19 +23,67 @@ fn corpus_string(value : Json) -> String raise { } ///| -fn corpus_count(value : Json) -> Int raise { +fn conformance_object( + value : Json, + id : String, + context : String, +) -> Map[String, Json] raise { + match value { + Object(fields) => fields + _ => fail(id + ": expected " + context + " object") + } +} + +///| +fn conformance_array( + value : Json, + id : String, + context : String, +) -> Array[Json] raise { + match value { + Array(values) => values + _ => fail(id + ": expected " + context + " array") + } +} + +///| +fn conformance_string( + value : Json, + id : String, + context : String, +) -> String raise { + match value { + String(value) => value + _ => fail(id + ": expected " + context + " string") + } +} + +///| +fn conformance_count(value : Json, id : String, context : String) -> Int raise { match value { Number(value, ..) => value.to_int() - _ => fail("expected conformance JSON number") + _ => fail(id + ": expected " + context + " number") } } ///| -fn corpus_bool(value : Json) -> Bool raise { +fn conformance_bool(value : Json, id : String, context : String) -> Bool raise { match value { True => true False => false - _ => fail("expected conformance JSON boolean") + _ => fail(id + ": expected " + context + " boolean") + } +} + +///| +fn conformance_field( + fields : Map[String, Json], + name : String, + id : String, +) -> Json raise { + match fields.get(name) { + Some(value) => value + None => fail(id + ": missing conformance JSON field " + name) } } @@ -181,15 +229,40 @@ fn conformance_at_pointer( ) -> Json raise { match pointer { "" => value - "/required" => corpus_object(value).get("required").unwrap() + "/required" => + conformance_field( + conformance_object(value, id, "rendered schema"), + "required", + id, + ) "/properties/nanoseconds" => - corpus_object(corpus_object(value).get("properties").unwrap()) - .get("nanoseconds") - .unwrap() + conformance_field( + conformance_object( + conformance_field( + conformance_object(value, id, "rendered schema"), + "properties", + id, + ), + id, + "rendered schema properties", + ), + "nanoseconds", + id, + ) "/properties/mantissa" => - corpus_object(corpus_object(value).get("properties").unwrap()) - .get("mantissa") - .unwrap() + conformance_field( + conformance_object( + conformance_field( + conformance_object(value, id, "rendered schema"), + "properties", + id, + ), + id, + "rendered schema properties", + ), + "mantissa", + id, + ) other => fail(id + ": unsupported conformance JSON pointer " + other) } } @@ -202,7 +275,7 @@ fn assert_conformance_subset( ) -> Unit raise { match expected { Object(expected_fields) => { - let actual_fields = corpus_object(actual) + let actual_fields = conformance_object(actual, id, "rendered schema") for entry in expected_fields { let (name, expected_value) = entry let actual_value = match actual_fields.get(name) { @@ -237,21 +310,27 @@ fn assert_conformance_semantic( @model.SchemaTypeBody::Future(None), @model.SchemaTypeBody::Stream(None), ] - let expected_fields = corpus_object(expected) + let expected_fields = conformance_object( + expected, id, "expected semantic result", + ) guard types.length() == - corpus_count(expected_fields.get("count").unwrap()) else { + conformance_count( + conformance_field(expected_fields, "count", id), + id, + "unsupported-leaf count", + ) else { fail(id + ": unsupported-leaf count differs") } for body in types { assert_conformance_subset( test_schema(body).to_json_schema(include_draft_marker=false), - expected_fields.get("schema").unwrap(), + conformance_field(expected_fields, "schema", id), id, ) } } "all-kinds" => { - let corpus_fields = corpus_object(corpus) + let corpus_fields = conformance_object(corpus, id, "conformance corpus") let supported = [ "ref", "bool", "s8", "s16", "s32", "s64", "u8", "u16", "u32", "u64", "f32", "f64", "char", "string", "record", "variant", "enum", "flags", "tuple", "list", @@ -259,21 +338,29 @@ fn assert_conformance_semantic( "datetime", "duration", "quantity", "union", "secret", "quota-token", "permission-card", "future", "stream", ] - let expected_names = corpus_array( - corpus_object(expected).get("names").unwrap(), - ).map(corpus_string) + let expected_names = conformance_array( + conformance_field( + conformance_object(expected, id, "expected semantic result"), + "names", + id, + ), + id, + "expected schema names", + ).map(value => conformance_string(value, id, "schema name")) guard supported == expected_names else { fail(id + ": supported schema kinds differ") } - guard corpus_array(corpus_fields.get("schemaKinds").unwrap()).map( - corpus_string, - ) == + guard conformance_array( + conformance_field(corpus_fields, "schemaKinds", id), + id, + "declared schema kinds", + ).map(value => conformance_string(value, id, "schema kind")) == expected_names else { fail(id + ": declared schema kinds differ") } } "all-restrictions" => { - let corpus_fields = corpus_object(corpus) + let corpus_fields = conformance_object(corpus, id, "conformance corpus") let supported = [ "numeric-minimum", "numeric-maximum", "numeric-unit", "text-languages", "text-min-length", "text-max-length", "text-regex", "binary-mime-types", "binary-min-bytes", @@ -281,21 +368,31 @@ fn assert_conformance_semantic( "url-schemes", "url-hosts", "quantity-base-unit", "quantity-suffixes", "quantity-minimum", "quantity-maximum", "union-prefix", "union-suffix", "union-regex", "union-field", ] - let expected_names = corpus_array( - corpus_object(expected).get("names").unwrap(), - ).map(corpus_string) + let expected_names = conformance_array( + conformance_field( + conformance_object(expected, id, "expected semantic result"), + "names", + id, + ), + id, + "expected restriction names", + ).map(value => conformance_string(value, id, "restriction name")) guard supported == expected_names else { fail(id + ": supported restriction kinds differ") } - guard corpus_array(corpus_fields.get("restrictionKinds").unwrap()).map( - corpus_string, - ) == + guard conformance_array( + conformance_field(corpus_fields, "restrictionKinds", id), + id, + "declared restriction kinds", + ).map(value => conformance_string(value, id, "restriction kind")) == expected_names else { fail(id + ": declared restriction kinds differ") } } "graph" => { - let expected_fields = corpus_object(expected) + let expected_fields = conformance_object( + expected, id, "expected semantic result", + ) let referenced = conformance_fixture("optional-record", id) let inline = test_schema( Record([ @@ -312,11 +409,20 @@ fn assert_conformance_semantic( let valid = @model.validate_graph(referenced.graph()).length() == 0 let equivalent = referenced.pack_json(Json::object({})) == inline.pack_json(Json::object({})) - guard valid == corpus_bool(expected_fields.get("valid").unwrap()) else { + guard valid == + conformance_bool( + conformance_field(expected_fields, "valid", id), + id, + "graph validity", + ) else { fail(id + ": graph validity differs") } guard equivalent == - corpus_bool(expected_fields.get("equivalent").unwrap()) else { + conformance_bool( + conformance_field(expected_fields, "equivalent", id), + id, + "graph equivalence", + ) else { fail(id + ": graph equivalence differs") } } @@ -341,7 +447,7 @@ fn conformance_reject_kind( try schema.validate_value(value) catch { _ => "constraint-violation" } noraise { - _ => fail("conformance case was accepted: " + id) + _ => fail(id + ": conformance case was accepted") } } } @@ -353,32 +459,46 @@ fn run_conformance_operation(operation : String) -> Unit raise { for test_case_json in corpus_array(root.get("cases").unwrap()) { let test_case = corpus_object(test_case_json) let id = corpus_string(test_case.get("id").unwrap()) - if corpus_string(test_case.get("operation").unwrap()) == operation { - let name = corpus_string(test_case.get("fixture").unwrap()) + if conformance_string( + conformance_field(test_case, "operation", id), + id, + "operation", + ) == + operation { + let name = conformance_string( + conformance_field(test_case, "fixture", id), + id, + "fixture", + ) match operation { "roundtrip" => { let schema = conformance_fixture(name, id) let actual = schema.unpack_json( - schema.pack_json(test_case.get("input").unwrap()), + schema.pack_json(conformance_field(test_case, "input", id)), ) catch { error => fail(id + ": " + repr(error)) } - guard actual == test_case.get("expected").unwrap() else { + guard actual == conformance_field(test_case, "expected", id) else { fail(id + ": roundtrip output differs") } } "reject" => { let schema = conformance_fixture(name, id) let inputs = match test_case.get("inputs") { - Some(values) => corpus_array(values) - None => [test_case.get("input").unwrap()] + Some(values) => conformance_array(values, id, "rejection inputs") + None => [conformance_field(test_case, "input", id)] } for input in inputs { let actual = conformance_reject_kind(schema, input, id) - let expected = corpus_string( - corpus_object(test_case.get("expected").unwrap()) - .get("kind") - .unwrap(), + let expected_fields = conformance_object( + conformance_field(test_case, "expected", id), + id, + "expected rejection", + ) + let expected = conformance_string( + conformance_field(expected_fields, "kind", id), + id, + "rejection kind", ) guard actual == expected else { fail(id + ": rejection category differs") @@ -391,20 +511,24 @@ fn run_conformance_operation(operation : String) -> Unit raise { conformance_fixture(name, id).to_json_schema( include_draft_marker=false, ), - corpus_string(test_case.get("path").unwrap()), + conformance_string( + conformance_field(test_case, "path", id), + id, + "JSON pointer", + ), id, ), - test_case.get("expected").unwrap(), + conformance_field(test_case, "expected", id), id, ) "semantic" => assert_conformance_semantic( name, - test_case.get("expected").unwrap(), + conformance_field(test_case, "expected", id), corpus, id, ) - other => fail("unknown requested conformance operation " + other) + other => fail(id + ": unknown requested conformance operation " + other) } } } @@ -442,7 +566,7 @@ test "reflection conformance corpus integrity" { } declared_seen.push(id) guard executed.any(value => value == id) else { - fail("undeclared conformance case ID " + id) + fail("declared conformance case ID has no case: " + id) } } } diff --git a/sdks/rust/golem-rust/src/agentic/reflection.rs b/sdks/rust/golem-rust/src/agentic/reflection.rs index d9fd1f0662..cef07a86ad 100644 --- a/sdks/rust/golem-rust/src/agentic/reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/reflection.rs @@ -1890,7 +1890,10 @@ mod tests { ]; assert_eq!( types.len(), - expected["count"].as_u64().unwrap() as usize, + expected["count"] + .as_u64() + .unwrap_or_else(|| panic!("{id}: missing unsupported-leaves count")) + as usize, "{id}" ); for ty in types { diff --git a/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts index 3445ea9da9..0fc54b2c5b 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts @@ -247,6 +247,35 @@ describe('SchemaRef JSON Schema', () => { }); it('renders declared bounds for every narrow integer and float family', () => { + for (const type of [ + t.s8({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), + t.s16({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), + t.s32({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), + t.u8({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), + t.u16({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), + t.u32({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), + ]) { + expect(schema(type).toJsonSchema()).toMatchObject({ + minimum: type.body.tag.startsWith('s') ? -12 : 2, + maximum: 12, + }); + } + + for (const type of [ + t.f32({ + min: { tag: 'float-bits', val: 0xbff0000000000000n }, + max: { tag: 'float-bits', val: 0x3fe0000000000000n }, + }), + t.f64({ + min: { tag: 'float-bits', val: 0xbff0000000000000n }, + max: { tag: 'float-bits', val: 0x3fe0000000000000n }, + }), + ]) { + expect(schema(type).toJsonSchema()).toMatchObject({ minimum: -1, maximum: 0.5 }); + } + }); + + it('clamps out-of-domain bounds to primitive ranges', () => { for (const [type, minimum, maximum] of [ [ t.s8({ @@ -288,10 +317,7 @@ describe('SchemaRef JSON Schema', () => { 2 ** 32 - 1, ], ] as const) { - expect(schema(type).toJsonSchema()).toMatchObject({ - minimum, - maximum, - }); + expect(schema(type).toJsonSchema()).toMatchObject({ minimum, maximum }); } expect( @@ -302,15 +328,6 @@ describe('SchemaRef JSON Schema', () => { }), ).toJsonSchema(), ).toMatchObject({ minimum: -3.4028234663852886e38, maximum: 3.4028234663852886e38 }); - - expect( - schema( - t.f64({ - min: { tag: 'float-bits', val: 0xbff0000000000000n }, - max: { tag: 'float-bits', val: 0x3fe0000000000000n }, - }), - ).toJsonSchema(), - ).toMatchObject({ minimum: -1, maximum: 0.5 }); }); it('renders enforceable rich-value allowlists and canonical base64url bytes', () => {