From 6615c5f7d084430aa057fe98db3747b333cf6954 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 11:21:55 +0000 Subject: [PATCH] fix(python): fan out only on JSON-array output, not any IEnumerable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old code iterated every IEnumerable, so `output = "hello"` emitted one item per character (a string is IEnumerable-of-char) and `output = {"a":1}` emitted the keys as items. Now the script's output is serialized first and the ValueKind decides: JSON array → per-element items; anything else → single item. `output = None` remains an empty emission. New tests cover string, dict, number, and None shapes. --- src/VolcanicCore.Nodes/PythonTransformNode.cs | 13 ++++-- .../PythonTransformNodeTests.cs | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/VolcanicCore.Nodes/PythonTransformNode.cs b/src/VolcanicCore.Nodes/PythonTransformNode.cs index 71169d2..d2773e2 100644 --- a/src/VolcanicCore.Nodes/PythonTransformNode.cs +++ b/src/VolcanicCore.Nodes/PythonTransformNode.cs @@ -59,12 +59,19 @@ public sealed class PythonTransformNode : IActionNode string.IsNullOrEmpty(formatted) ? ex.Message : formatted); } - if (!scope.TryGetVariable("output", out object? outputObj)) + if (!scope.TryGetVariable("output", out object? outputObj) || outputObj is null) return Task.FromResult>>(new[] { Array.Empty() }); + // Fan out only when `output` serializes to a JSON array. A naive + // `IEnumerable` check would treat a bare string as an iterable of chars + // (`output = "hello"` → 5 items) and a bare dict as an iterable of keys. + // Serializing first and dispatching on ValueKind gives one predictable rule. + var element = JsonToPython.ToJson(outputObj); var outItems = new List(); - if (outputObj is System.Collections.IEnumerable ie) - foreach (var v in ie) outItems.Add(new Item(JsonToPython.ToJson(v))); + if (element.ValueKind == JsonValueKind.Array) + foreach (var el in element.EnumerateArray()) outItems.Add(new Item(el.Clone())); + else + outItems.Add(new Item(element)); return Task.FromResult>>(new[] { outItems }); } diff --git a/tests/VolcanicCore.Tests/PythonTransformNodeTests.cs b/tests/VolcanicCore.Tests/PythonTransformNodeTests.cs index 3624190..580599f 100644 --- a/tests/VolcanicCore.Tests/PythonTransformNodeTests.cs +++ b/tests/VolcanicCore.Tests/PythonTransformNodeTests.cs @@ -73,6 +73,51 @@ public class PythonTransformNodeTests Assert.Contains("line 2", ex.Message); } + [Fact] + public async Task Bare_string_output_is_a_single_item_not_one_char_per_item() + { + var node = new PythonTransformNode(); + var outs = await node.ExecuteAsync(Ctx("output = 'hello'"), + new IReadOnlyList[] { Array.Empty() }, CancellationToken.None); + Assert.Single(outs); + Assert.Single(outs[0]); + Assert.Equal("hello", outs[0][0].Json.GetString()); + } + + [Fact] + public async Task Bare_dict_output_is_a_single_item_not_one_key_per_item() + { + var node = new PythonTransformNode(); + var outs = await node.ExecuteAsync(Ctx("output = {'test': 'hi', 'n': 3}"), + new IReadOnlyList[] { Array.Empty() }, CancellationToken.None); + Assert.Single(outs); + Assert.Single(outs[0]); + Assert.Equal(JsonValueKind.Object, outs[0][0].Json.ValueKind); + Assert.Equal("hi", outs[0][0].Json.GetProperty("test").GetString()); + Assert.Equal(3, outs[0][0].Json.GetProperty("n").GetInt32()); + } + + [Fact] + public async Task Bare_number_output_is_a_single_item() + { + var node = new PythonTransformNode(); + var outs = await node.ExecuteAsync(Ctx("output = 42"), + new IReadOnlyList[] { Array.Empty() }, CancellationToken.None); + Assert.Single(outs); + Assert.Single(outs[0]); + Assert.Equal(42, outs[0][0].Json.GetInt64()); + } + + [Fact] + public async Task None_output_yields_empty_list() + { + var node = new PythonTransformNode(); + var outs = await node.ExecuteAsync(Ctx("output = None"), + new IReadOnlyList[] { Array.Empty() }, CancellationToken.None); + Assert.Single(outs); + Assert.Empty(outs[0]); + } + [Fact] public async Task Syntax_error_message_contains_python_traceback() {