From 2839ce3ccea12f609ac3788fbaf8ee19cca23fce Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Fri, 11 Sep 2026 00:00:08 +0200 Subject: [PATCH 1/4] feat(flow): support complete child input passthrough --- docs/architecture.md | 2 +- .../FlowValidationService.cs | 2 ++ .../Components/FlowDesigner.razor | 29 +++++++++++++++---- .../Components/FlowDesignerStrings.fr-FR.resx | 2 +- .../Components/FlowDesignerStrings.resx | 2 +- .../FlowCallAuthoringTests.cs | 25 ++++++++++++++++ .../NotificationDeliveryTests.cs | 1 + .../FlowDesignerReadOnlyTests.cs | 13 +++++++++ 8 files changed, 67 insertions(+), 9 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 5ab00112..7d2aa6b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -179,7 +179,7 @@ Entry / Trigger / REST / Console -> RootFlowSubmissionService WorkItem execution -> revalidates durable scope -> local graph execution or isolated MAF orchestration adapter ``` -The Flow module is physically independent and owns editable typed graph drafts, immutable published snapshots, constrained expressions, and the provider-neutral Flow Run model. The graph vocabulary includes one generic `Flow` call and one generic governed `Tool` call; resource catalogs populate their targets without adding provider-specific node types. Flow Application validates their logical references, mappings, and published schemas while remaining independent of Runtime and MCP implementations. The local executor traverses `Input`, `Agent`, `Flow`, `Tool`, `Router`, `Condition`, `Transform`, `Output`, and `Failure` steps sequentially. Infrastructure adapts agent steps, Management resource lookups, and Tool steps to the shared Tool Execution Pipeline. A Tool step records stable logical and per-attempt invocation identities, preserves workspace and principal scope, and projects Tool lifecycle and governance events into its owning Flow Run. +The Flow module is physically independent and owns editable typed graph drafts, immutable published snapshots, constrained expressions, and the provider-neutral Flow Run model. The graph vocabulary includes one generic `Flow` call and one generic governed `Tool` call; resource catalogs populate their targets without adding provider-specific node types. Flow Application validates their logical references, mappings, and published schemas while remaining independent of Runtime and MCP implementations. A Flow call can map fields explicitly or use the Designer's explicit complete-input mode, represented canonically by `${input}`, to pass the structured parent input unchanged. The local executor traverses `Input`, `Agent`, `Flow`, `Tool`, `Router`, `Condition`, `Transform`, `Output`, and `Failure` steps sequentially. Infrastructure adapts agent steps, Management resource lookups, and Tool steps to the shared Tool Execution Pipeline. A Tool step records stable logical and per-attempt invocation identities, preserves workspace and principal scope, and projects Tool lifecycle and governance events into its owning Flow Run. A Flow call creates a deterministic durable child Flow Run for the parent step attempt. The parent persists `WaitingForChild`, clears its execution lease, and releases the worker. The child captures the resolved immutable version, parent/root causality, nesting depth, and the parent's tenant, Workspace, Principal, interaction, and Work Task identities. A terminal child atomically moves a waiting parent back to `Pending`; replay reconstructs completed step outputs and resumes at the calling step. Startup recovery requeues lost parents or children, cancellation walks the active descendant tree, and configured depth and descendant limits bound composition. Both local SQLite and optional PostgreSQL persist these additions inside the existing Flow document payload, so this increment does not require a relational schema migration. diff --git a/src/Agentstration.Flow.Application/FlowValidationService.cs b/src/Agentstration.Flow.Application/FlowValidationService.cs index 2ed072c7..974aeed8 100644 --- a/src/Agentstration.Flow.Application/FlowValidationService.cs +++ b/src/Agentstration.Flow.Application/FlowValidationService.cs @@ -209,6 +209,8 @@ private async Task ValidateFlowCallAsync( private static void ValidateMappingAgainstSchema(FlowCallStepDefinition step, JsonElement? schema, List issues) { if (schema is not { ValueKind: JsonValueKind.Object } value) return; + if (step.InputMapping is { ValueKind: JsonValueKind.String } mapping + && string.Equals(mapping.GetString(), "${input}", StringComparison.Ordinal)) return; ValidateMappingAgainstSchema(step.Name, "inputMapping", step.InputMapping, value, "flow_input_mapping", issues); } diff --git a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor index c2cd007a..dded4598 100644 --- a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor +++ b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor @@ -113,15 +113,21 @@ else @foreach (var version in flowVersions) { } } - @if (SelectedFlowVersion?.InputSchema is { ValueKind: JsonValueKind.Object } inputSchema - && inputSchema.TryGetProperty("properties", out var inputProperties) - && inputProperties.ValueKind == JsonValueKind.Object) + @if (SelectedFlowVersion is not null) {
@T("InputMapping") - @foreach (var property in inputProperties.EnumerateObject()) + + @T("PassCompleteInputHelp") + @if (!PassesCompleteInput(flowCall.InputMapping) + && SelectedFlowVersion.InputSchema is { ValueKind: JsonValueKind.Object } inputSchema + && inputSchema.TryGetProperty("properties", out var inputProperties) + && inputProperties.ValueKind == JsonValueKind.Object) { - var propertyName = property.Name; - + @foreach (var property in inputProperties.EnumerateObject()) + { + var propertyName = property.Name; + + } }
} @@ -395,6 +401,15 @@ else mapping[propertyName] = JsonSerializer.SerializeToElement(Text(e)); return UpdateSelectedAsync(call with { InputMapping = JsonSerializer.SerializeToElement(mapping) }); } + private Task UpdateFlowPassthroughAsync(ChangeEventArgs e) + { + if (SelectedStep is not FlowCallStepDefinition call) return Task.CompletedTask; + var enabled = e.Value is bool value && value; + var mapping = enabled + ? JsonSerializer.SerializeToElement("${input}") + : JsonSerializer.SerializeToElement(new { }); + return UpdateSelectedAsync(call with { InputMapping = mapping }); + } private Task UpdateToolTargetAsync(ChangeEventArgs e) { if (SelectedStep is not ToolFlowStepDefinition tool || !TryParseToolKey(Text(e), out var target)) return Task.CompletedTask; @@ -456,6 +471,8 @@ else private static string MappingValue(JsonElement? mapping, string propertyName) => mapping is { ValueKind: JsonValueKind.Object } value && value.TryGetProperty(propertyName, out var property) ? property.ValueKind == JsonValueKind.String ? property.GetString() ?? string.Empty : property.GetRawText() : string.Empty; + private static bool PassesCompleteInput(JsonElement? mapping) => mapping is { ValueKind: JsonValueKind.String } value + && string.Equals(value.GetString(), "${input}", StringComparison.Ordinal); private static string Text(ChangeEventArgs e) => e.Value?.ToString()?.Trim() ?? string.Empty; private static string? EmptyToNull(string value) => string.IsNullOrWhiteSpace(value) ? null : value; private static string NextVersion(string value) { var parts = value.Split('.'); return parts.Length == 3 && int.TryParse(parts[2], out var patch) ? $"{parts[0]}.{parts[1]}.{patch + 1}" : "1.0.0"; } diff --git a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx index a081ef16..1054592e 100644 --- a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx +++ b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx @@ -24,7 +24,7 @@ AgentSortie du routeur Flow appeléStratégie de version Version activeVersion exacte - Version exacteMappage d’entréePropriétés de sortie disponibles + Version exacteMappage d’entréeTransmettre l’entrée complèteLe Flow enfant reçoit l’entrée complète du parent sans modification.Propriétés de sortie disponibles Tool appeléMappage des arguments Ce Tool est désactivé.Ce Tool est indisponible auprès de son fournisseur.Ce Tool nécessite une approbation avant son appel. Charger les agentsTout ajouter diff --git a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx index bd6aa0cf..9b262bf5 100644 --- a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx +++ b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx @@ -30,7 +30,7 @@ AgentRouter output Called FlowVersion strategy Active versionExact version - Exact versionInput mappingAvailable output properties + Exact versionInput mappingPass the complete inputThe child Flow receives the complete parent input unchanged.Available output properties Called ToolArguments mapping This Tool is disabled.This Tool is unavailable from its provider.This Tool requires approval before invocation. Load agentsAdd all diff --git a/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs b/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs index c4fa23d9..8484a263 100644 --- a/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs +++ b/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs @@ -67,6 +67,31 @@ public async Task FlowCallValidationUsesWorkspaceVersionSchemasAndRejectsIncompa Assert.AreEqual(ResourceNamespace.Default, resolver.OwnerNamespace); } + [TestMethod] + public async Task FlowCallValidationAcceptsCompleteInputPassthrough() + { + var inputSchema = JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new { article = new { type = "string" } }, + required = new[] { "article" } + }); + var resolver = new FlowCallResolverStub(new(new("analysis"), "3.0.0", inputSchema, JsonSerializer.SerializeToElement(new { type = "object" }))); + var graph = Graph(new FlowCallStepDefinition + { + Name = "analyze", + Flow = new("analysis"), + InputMapping = JsonSerializer.SerializeToElement("${input}") + }); + + var result = await new FlowGraphValidator(resolver).ValidateAsync( + graph, + new FlowValidationContext(true, TestScope.WorkspaceId, new("parent")), + default); + + Assert.IsTrue(result.IsValid, string.Join(Environment.NewLine, result.Issues.Select(issue => issue.Message))); + } + [TestMethod] public async Task RepositoryResolverFindsNamespacedPublishedVersionsAndIndirectCycles() { diff --git a/tests/Agentstration.Management.Tests/NotificationDeliveryTests.cs b/tests/Agentstration.Management.Tests/NotificationDeliveryTests.cs index dac4b894..7d632fad 100644 --- a/tests/Agentstration.Management.Tests/NotificationDeliveryTests.cs +++ b/tests/Agentstration.Management.Tests/NotificationDeliveryTests.cs @@ -145,6 +145,7 @@ await factory.Services.GetRequiredService() Assert.IsNotNull(delivery); Assert.AreEqual("notification-delivery", delivery.Value.FlowId.Value); Assert.AreEqual("1.0.0", delivery.Value.FlowVersion); + Assert.AreEqual(arguments.GetRawText(), delivery.Value.Input.GetRawText()); var notification = (await factory.Services.GetRequiredService() .ListNotificationsAsync(workspaceId, null, default)).Single(); Assert.AreEqual(delivery.Value.Id, notification.SourceRunId); diff --git a/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs b/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs index 7cfc8d16..f1bd876d 100644 --- a/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs +++ b/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs @@ -105,6 +105,19 @@ public void EditablePaletteUsesOneGenericFlowCardAndShowsTheSelectedContract() StringAssert.Contains(rendered.Markup, "article"); StringAssert.Contains(rendered.Markup, "summary"); }); + + rendered.Find("[data-testid='flow-pass-complete-input']").Change(true); + var call = Assert.IsInstanceOfType(context.Services.GetRequiredService() + .State.Resource!.Definition.Steps.Single(step => step.Type() == "flow")); + Assert.AreEqual(JsonValueKind.String, call.InputMapping?.ValueKind); + Assert.AreEqual("${input}", call.InputMapping?.GetString()); + StringAssert.Contains(rendered.Markup, "Pass the complete input"); + + rendered.Find("[data-testid='flow-pass-complete-input']").Change(false); + call = Assert.IsInstanceOfType(context.Services.GetRequiredService() + .State.Resource!.Definition.Steps.Single(step => step.Type() == "flow")); + Assert.AreEqual(JsonValueKind.Object, call.InputMapping?.ValueKind); + rendered.WaitForAssertion(() => StringAssert.Contains(rendered.Markup, "article")); } [TestMethod] From 1550fdabadeaf3d7bef857f7ec9b2f897b31c695 Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Fri, 11 Sep 2026 00:56:57 +0200 Subject: [PATCH 2/4] fix(flow): load child contract on card selection --- .../Components/FlowDesigner.razor | 7 ++- .../FlowDesignerReadOnlyTests.cs | 52 +++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor index dded4598..393cb85f 100644 --- a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor +++ b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor @@ -69,7 +69,7 @@ else
@@ -321,6 +321,11 @@ else private Task MoveStepAsync(MoveStepCommand command) => DispatchAsync(command); private Task CreateTransitionAsync(FlowTransitionDefinition transition) => DispatchAsync(new AddTransitionCommand(transition)); private Task RemoveTransitionAsync(string id) => DispatchAsync(new RemoveTransitionCommand(id)); + private async Task SelectStepAsync(string? name) + { + Editor.SelectStep(name); + if (SelectedStep is FlowCallStepDefinition call) await LoadFlowVersionsAsync(call.Flow); + } private async Task LayoutAsync(bool vertical) { await DispatchAsync(new ApplyAutoLayoutCommand(vertical)); diff --git a/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs b/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs index f1bd876d..61b61c02 100644 --- a/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs +++ b/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs @@ -150,6 +150,46 @@ public void EditablePaletteUsesOneGenericToolCardAndShowsTheSelectedSchema() }); } + [TestMethod] + public async Task ExistingFlowCardLoadsItsContractWhenSelectedAndPersistsPassthrough() + { + using var culture = new CultureScope("en-US"); + using var context = new BunitContext(); + var definition = new FlowGraphDefinition + { + EntryStep = "input", + Steps = + [ + new InputFlowStepDefinition { Name = "input" }, + new FlowCallStepDefinition + { + Name = "deliver", + Flow = new("analysis", Namespace: new("pack.news")), + InputMapping = JsonSerializer.SerializeToElement(new { }) + } + ], + Transitions = [new("input-deliver", "input", "completed", "deliver")] + }; + context.Services.AddSingleton(new BackendStub(readOnly: false, definition)); + context.Services.AddSingleton(new ResourceProviderStub()); + context.Services.AddSingleton(); + context.Services.AddLocalization(options => options.ResourcesPath = "Resources"); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.JSInterop.Setup("ZBlazorDiagrams.getBoundingClientRect", _ => true) + .SetResult(new Rectangle(0, 0, 1024, 768)); + + var rendered = context.Render(parameters => parameters + .Add(component => component.ResourceId, "parent")); + var canvas = rendered.FindComponent(); + await rendered.InvokeAsync(() => canvas.Instance.SelectedStepChanged.InvokeAsync("deliver")); + + rendered.WaitForAssertion(() => Assert.HasCount(1, rendered.FindAll("[data-testid='flow-pass-complete-input']"))); + rendered.Find("[data-testid='flow-pass-complete-input']").Change(true); + var call = Assert.IsInstanceOfType(context.Services.GetRequiredService() + .State.Resource!.Definition.Steps.Single(step => step.Name == "deliver")); + Assert.AreEqual("${input}", call.InputMapping?.GetString()); + } + private sealed class ResourceProviderStub : IFlowDesignerResourceProvider { public Task> GetAgentsAsync(CancellationToken cancellationToken) => @@ -175,8 +215,12 @@ public Task> GetToolsAsync(CancellationToken can private sealed class BackendStub : IFlowDesignerBackend { private readonly bool readOnly; - private readonly FlowDraftResponse draft = CreateDraft(); - public BackendStub(bool readOnly = true) => this.readOnly = readOnly; + private readonly FlowDraftResponse draft; + public BackendStub(bool readOnly = true, FlowGraphDefinition? definition = null) + { + this.readOnly = readOnly; + draft = CreateDraft(definition); + } public int SaveCount { get; private set; } public FlowDesignerTarget? LoadedTarget { get; private set; } public Task LoadAsync(FlowDesignerTarget target, CancellationToken cancellationToken) @@ -192,10 +236,10 @@ public Task LoadAsync(FlowDesignerTarget target, Cancell public Task PublishAsync(FlowDesignerTarget target, PublishFlowDraftRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task RunDraftAsync(FlowDesignerTarget target, CreateFlowRunRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); - private static FlowDraftResponse CreateDraft() + private static FlowDraftResponse CreateDraft(FlowGraphDefinition? definition = null) { var now = DateTimeOffset.Parse("2026-08-05T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture); - var definition = new FlowGraphDefinition { EntryStep = "input", Steps = [new InputFlowStepDefinition { Name = "input" }], Transitions = [] }; + definition ??= new FlowGraphDefinition { EntryStep = "input", Steps = [new InputFlowStepDefinition { Name = "input" }], Transitions = [] }; return new(new FlowDraft { WorkspaceId = WorkspaceId, Id = "draft", FlowId = new("sample"), DisplayName = "Sample", Definition = definition, CreatedAt = now, UpdatedAt = now }, "\"etag\""); } From 70f52292041af63da83d31cecf5052f55bff93ff Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Fri, 11 Sep 2026 01:20:57 +0200 Subject: [PATCH 3/4] fix(flow): pass input from selected transition --- docs/architecture.md | 2 +- .../FlowRunService.Graph.cs | 19 +++++- .../FlowValidationService.cs | 24 ++++++- .../Components/FlowDesigner.razor | 34 +++++++--- .../Components/FlowDesignerStrings.fr-FR.resx | 2 +- .../Components/FlowDesignerStrings.resx | 2 +- .../FlowCallAuthoringTests.cs | 6 +- .../FlowNestedRunTests.cs | 66 ++++++++++++++++++- .../FlowDesignerReadOnlyTests.cs | 19 ++++-- 9 files changed, 146 insertions(+), 28 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7d2aa6b9..dde7108b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -179,7 +179,7 @@ Entry / Trigger / REST / Console -> RootFlowSubmissionService WorkItem execution -> revalidates durable scope -> local graph execution or isolated MAF orchestration adapter ``` -The Flow module is physically independent and owns editable typed graph drafts, immutable published snapshots, constrained expressions, and the provider-neutral Flow Run model. The graph vocabulary includes one generic `Flow` call and one generic governed `Tool` call; resource catalogs populate their targets without adding provider-specific node types. Flow Application validates their logical references, mappings, and published schemas while remaining independent of Runtime and MCP implementations. A Flow call can map fields explicitly or use the Designer's explicit complete-input mode, represented canonically by `${input}`, to pass the structured parent input unchanged. The local executor traverses `Input`, `Agent`, `Flow`, `Tool`, `Router`, `Condition`, `Transform`, `Output`, and `Failure` steps sequentially. Infrastructure adapts agent steps, Management resource lookups, and Tool steps to the shared Tool Execution Pipeline. A Tool step records stable logical and per-attempt invocation identities, preserves workspace and principal scope, and projects Tool lifecycle and governance events into its owning Flow Run. +The Flow module is physically independent and owns editable typed graph drafts, immutable published snapshots, constrained expressions, and the provider-neutral Flow Run model. The graph vocabulary includes one generic `Flow` call and one generic governed `Tool` call; resource catalogs populate their targets without adding provider-specific node types. Flow Application validates their logical references, mappings, and published schemas while remaining independent of Runtime and MCP implementations. A Flow call can map fields explicitly or use the Designer's complete-input source selector to pass either the initial structured input (`${input}`) or the output carried by the transition actually taken into the step (`${transition.output}`) unchanged. The latter remains correct when several branches converge on the same Flow call. The local executor traverses `Input`, `Agent`, `Flow`, `Tool`, `Router`, `Condition`, `Transform`, `Output`, and `Failure` steps sequentially. Infrastructure adapts agent steps, Management resource lookups, and Tool steps to the shared Tool Execution Pipeline. A Tool step records stable logical and per-attempt invocation identities, preserves workspace and principal scope, and projects Tool lifecycle and governance events into its owning Flow Run. A Flow call creates a deterministic durable child Flow Run for the parent step attempt. The parent persists `WaitingForChild`, clears its execution lease, and releases the worker. The child captures the resolved immutable version, parent/root causality, nesting depth, and the parent's tenant, Workspace, Principal, interaction, and Work Task identities. A terminal child atomically moves a waiting parent back to `Pending`; replay reconstructs completed step outputs and resumes at the calling step. Startup recovery requeues lost parents or children, cancellation walks the active descendant tree, and configured depth and descendant limits bound composition. Both local SQLite and optional PostgreSQL persist these additions inside the existing Flow document payload, so this increment does not require a relational schema migration. diff --git a/src/Agentstration.Flow.Application/FlowRunService.Graph.cs b/src/Agentstration.Flow.Application/FlowRunService.Graph.cs index 697ea4e4..f50bf23a 100644 --- a/src/Agentstration.Flow.Application/FlowRunService.Graph.cs +++ b/src/Agentstration.Flow.Application/FlowRunService.Graph.cs @@ -19,6 +19,9 @@ private async Task ExecuteGraphAsync(StoredFlowRun initial, CancellationToken st var resumedChildStep = stored.Value.Steps.SingleOrDefault(step => step.Status == FlowStepRunStatus.Running && step.ChildFlowRunId is not null); var currentName = resumedChildStep?.StepName ?? graph.EntryStep; + var incomingTransition = resumedChildStep is null + ? null + : SelectedIncomingTransition(graph, stored.Value, currentName); var executed = stored.Value.Steps .Where(step => step.Status is FlowStepRunStatus.Succeeded or FlowStepRunStatus.Failed) .Select(step => step.StepName) @@ -32,7 +35,10 @@ private async Task ExecuteGraphAsync(StoredFlowRun initial, CancellationToken st if (resumedChildStep?.StepName != step.Name) stored = await StartStepAsync(stored, step.Name, runToken); resumedChildStep = null; - var context = new FlowExecutionContext(stored.Value.Input, outputs); + var transitionOutput = incomingTransition is null + ? null + : outputs.GetValueOrDefault(incomingTransition.FromStep)?.Clone(); + var context = new FlowExecutionContext(stored.Value.Input, outputs, transitionOutput); JsonElement? output; string eventName; FlowAgentExecutionResult? agentResult = null; @@ -149,6 +155,7 @@ private async Task ExecuteGraphAsync(StoredFlowRun initial, CancellationToken st if (transition is null && stepError is not null) throw new FlowValidationException(stepError.Code, stepError.Details ?? stepError.Message); if (transition is null) throw new FlowValidationException("flow_transition_missing", $"No '{eventName}' transition leaves step '{step.Name}'."); + incomingTransition = transition; currentName = transition.ToStep; } if (finalOutput is null) throw new FlowValidationException("flow_output_missing", "The Flow completed without reaching an Output step."); @@ -167,6 +174,16 @@ await SaveAsync(stored, stored.Value with await EmitAsync(stored.Value.WorkspaceId, stored.Value.Id, FlowRunEventType.FlowRunCompleted, null, null, stoppingToken); } + private static FlowTransitionDefinition? SelectedIncomingTransition( + FlowGraphDefinition graph, + FlowRun run, + string stepName) => + graph.Transitions.FirstOrDefault(transition => + transition.ToStep == stepName + && run.Steps.Any(step => + step.StepName == transition.FromStep + && step.SelectedTransition == transition.Id)); + private async Task FinishGraphStepAsync(StoredFlowRun stored, string name, JsonElement? output, string? transition, CancellationToken token) { var now = timeProvider.GetUtcNow(); diff --git a/src/Agentstration.Flow.Application/FlowValidationService.cs b/src/Agentstration.Flow.Application/FlowValidationService.cs index 974aeed8..1ff2cc40 100644 --- a/src/Agentstration.Flow.Application/FlowValidationService.cs +++ b/src/Agentstration.Flow.Application/FlowValidationService.cs @@ -210,10 +210,18 @@ private static void ValidateMappingAgainstSchema(FlowCallStepDefinition step, Js { if (schema is not { ValueKind: JsonValueKind.Object } value) return; if (step.InputMapping is { ValueKind: JsonValueKind.String } mapping - && string.Equals(mapping.GetString(), "${input}", StringComparison.Ordinal)) return; + && FlowExpressionParser.TryParse(mapping.GetString()!, out var expression, out _) + && IsCompleteObjectReference(expression.Body)) return; ValidateMappingAgainstSchema(step.Name, "inputMapping", step.InputMapping, value, "flow_input_mapping", issues); } + private static bool IsCompleteObjectReference(string expression) => + string.Equals(expression, "input", StringComparison.Ordinal) + || string.Equals(expression, "transition.output", StringComparison.Ordinal) + || (expression.StartsWith("steps.", StringComparison.Ordinal) + && expression.EndsWith(".output", StringComparison.Ordinal) + && expression.Count(character => character == '.') == 2); + private static void ValidateMappingAgainstSchema( string stepName, string propertyPath, @@ -316,7 +324,10 @@ public sealed record ParsedExpression(string Source, string Body); public sealed record ExpressionParseResult(ParsedExpression? Expression, string? Error) { public bool IsValid => Expression is not null; } public sealed record ExpressionValidationResult(bool IsValid, string? Error = null); public sealed record FlowExpressionContext(IReadOnlyCollection StepNames); -public sealed record FlowExecutionContext(JsonElement Input, IReadOnlyDictionary StepOutputs); +public sealed record FlowExecutionContext( + JsonElement Input, + IReadOnlyDictionary StepOutputs, + JsonElement? TransitionOutput = null); public interface IExpressionParser { ExpressionParseResult Parse(string expression); } public interface IExpressionValidator { ExpressionValidationResult Validate(ParsedExpression expression, FlowExpressionContext context); } @@ -355,7 +366,13 @@ internal static bool TryParse(string expression, out ParsedExpression parsed, ou var body = expression[2..^1].Trim(); if (body.Length == 0 || body.Contains(';') || body.Contains('(') || body.Contains(')')) { error = "The expression contains unsupported syntax."; return false; } var first = ComparisonParts(body)[0]; - if (!first.StartsWith("input", StringComparison.Ordinal) && !first.StartsWith("steps.", StringComparison.Ordinal)) { error = "Expressions may reference only input or step outputs."; return false; } + if (!first.StartsWith("input", StringComparison.Ordinal) + && !first.StartsWith("steps.", StringComparison.Ordinal) + && !first.StartsWith("transition.output", StringComparison.Ordinal)) + { + error = "Expressions may reference only input, the incoming transition output, or step outputs."; + return false; + } parsed = new ParsedExpression(expression, body); return true; } @@ -374,6 +391,7 @@ private static string[] ComparisonParts(string body) var segments = path.Split('.'); JsonElement? current; var offset = 1; if (segments[0] == "input") current = context.Input; + else if (segments[0] == "transition") { current = context.TransitionOutput; offset = 2; } else { if (segments.Length < 3 || !context.StepOutputs.TryGetValue(segments[1], out current)) return null; offset = segments[2] == "output" ? 3 : 2; } for (var index = offset; index < segments.Length; index++) { diff --git a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor index 393cb85f..1b537354 100644 --- a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor +++ b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor @@ -116,9 +116,13 @@ else @if (SelectedFlowVersion is not null) {
@T("InputMapping") - - @T("PassCompleteInputHelp") - @if (!PassesCompleteInput(flowCall.InputMapping) + + @T("InputSourceHelp") + @if (!UsesCompleteInputSource(flowCall) && SelectedFlowVersion.InputSchema is { ValueKind: JsonValueKind.Object } inputSchema && inputSchema.TryGetProperty("properties", out var inputProperties) && inputProperties.ValueKind == JsonValueKind.Object) @@ -406,13 +410,16 @@ else mapping[propertyName] = JsonSerializer.SerializeToElement(Text(e)); return UpdateSelectedAsync(call with { InputMapping = JsonSerializer.SerializeToElement(mapping) }); } - private Task UpdateFlowPassthroughAsync(ChangeEventArgs e) + private Task UpdateFlowInputSourceAsync(ChangeEventArgs e) { if (SelectedStep is not FlowCallStepDefinition call) return Task.CompletedTask; - var enabled = e.Value is bool value && value; - var mapping = enabled - ? JsonSerializer.SerializeToElement("${input}") - : JsonSerializer.SerializeToElement(new { }); + var source = Text(e); + var mapping = source switch + { + "input" => JsonSerializer.SerializeToElement("${input}"), + "transition" => JsonSerializer.SerializeToElement("${transition.output}"), + _ => JsonSerializer.SerializeToElement(new { }) + }; return UpdateSelectedAsync(call with { InputMapping = mapping }); } private Task UpdateToolTargetAsync(ChangeEventArgs e) @@ -476,8 +483,15 @@ else private static string MappingValue(JsonElement? mapping, string propertyName) => mapping is { ValueKind: JsonValueKind.Object } value && value.TryGetProperty(propertyName, out var property) ? property.ValueKind == JsonValueKind.String ? property.GetString() ?? string.Empty : property.GetRawText() : string.Empty; - private static bool PassesCompleteInput(JsonElement? mapping) => mapping is { ValueKind: JsonValueKind.String } value - && string.Equals(value.GetString(), "${input}", StringComparison.Ordinal); + private static string CompleteInputSource(FlowCallStepDefinition call) + { + if (call.InputMapping is not { ValueKind: JsonValueKind.String } mapping) return string.Empty; + var expression = mapping.GetString(); + if (string.Equals(expression, "${input}", StringComparison.Ordinal)) return "input"; + if (string.Equals(expression, "${transition.output}", StringComparison.Ordinal)) return "transition"; + return string.Empty; + } + private static bool UsesCompleteInputSource(FlowCallStepDefinition call) => !string.IsNullOrEmpty(CompleteInputSource(call)); private static string Text(ChangeEventArgs e) => e.Value?.ToString()?.Trim() ?? string.Empty; private static string? EmptyToNull(string value) => string.IsNullOrWhiteSpace(value) ? null : value; private static string NextVersion(string value) { var parts = value.Split('.'); return parts.Length == 3 && int.TryParse(parts[2], out var patch) ? $"{parts[0]}.{parts[1]}.{patch + 1}" : "1.0.0"; } diff --git a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx index 1054592e..a5fc6355 100644 --- a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx +++ b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx @@ -24,7 +24,7 @@ AgentSortie du routeur Flow appeléStratégie de version Version activeVersion exacte - Version exacteMappage d’entréeTransmettre l’entrée complèteLe Flow enfant reçoit l’entrée complète du parent sans modification.Propriétés de sortie disponibles + Version exacteMappage d’entréeSource de l’entrée complèteMapper explicitement les propriétésEntrée initiale du FlowSortie de la transition entranteSélectionnez l’entrée initiale ou la sortie portée par la transition empruntée vers cette étape, ou mappez chaque propriété ci-dessous.Propriétés de sortie disponibles Tool appeléMappage des arguments Ce Tool est désactivé.Ce Tool est indisponible auprès de son fournisseur.Ce Tool nécessite une approbation avant son appel. Charger les agentsTout ajouter diff --git a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx index 9b262bf5..a8ea80a8 100644 --- a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx +++ b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx @@ -30,7 +30,7 @@ AgentRouter output Called FlowVersion strategy Active versionExact version - Exact versionInput mappingPass the complete inputThe child Flow receives the complete parent input unchanged.Available output properties + Exact versionInput mappingComplete input sourceMap properties explicitlyInitial Flow inputIncoming transition outputSelect the initial input or the output carried by the transition taken into this step, or map each property below.Available output properties Called ToolArguments mapping This Tool is disabled.This Tool is unavailable from its provider.This Tool requires approval before invocation. Load agentsAdd all diff --git a/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs b/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs index 8484a263..b2bf7b92 100644 --- a/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs +++ b/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs @@ -68,7 +68,9 @@ public async Task FlowCallValidationUsesWorkspaceVersionSchemasAndRejectsIncompa } [TestMethod] - public async Task FlowCallValidationAcceptsCompleteInputPassthrough() + [DataRow("${input}")] + [DataRow("${transition.output}")] + public async Task FlowCallValidationAcceptsCompleteInputPassthrough(string inputMapping) { var inputSchema = JsonSerializer.SerializeToElement(new { @@ -81,7 +83,7 @@ public async Task FlowCallValidationAcceptsCompleteInputPassthrough() { Name = "analyze", Flow = new("analysis"), - InputMapping = JsonSerializer.SerializeToElement("${input}") + InputMapping = JsonSerializer.SerializeToElement(inputMapping) }); var result = await new FlowGraphValidator(resolver).ValidateAsync( diff --git a/tests/Agentstration.Application.Tests/FlowNestedRunTests.cs b/tests/Agentstration.Application.Tests/FlowNestedRunTests.cs index b3cbee3c..1a94169a 100644 --- a/tests/Agentstration.Application.Tests/FlowNestedRunTests.cs +++ b/tests/Agentstration.Application.Tests/FlowNestedRunTests.cs @@ -59,6 +59,28 @@ public async Task FlowCallSuspendsCreatesOneDurableChildAndResumesWithItsOutput( Assert.AreEqual(1, events.Count(item => item.Type == FlowRunEventType.FlowRunResumedFromChild)); } + [TestMethod] + public async Task FlowCallPassesTheIncomingTransitionOutputToTheChild() + { + await using var fixture = await FlowFixture.CreateAsync(); + await CreatePublishedGraphAsync(fixture, "child", ChildGraph()); + var parent = await CreatePublishedGraphAsync(fixture, "parent", AgentThenFlowGraph()); + var runs = Service(fixture, new TestFlowRunQueue(), agents: new StructuredAgentExecutor()); + using var input = JsonDocument.Parse("""{"article":"original item"}"""); + + var pending = await runs.CreateAsync( + parent.Value.Id, "1.0.0", "local", FlowRunTrigger.Manual, "tester", "agent-child-input", + input.RootElement, TestScope, default); + await runs.ExecuteAsync(new(pending.Value.Id, TestScope), default); + + var waiting = (await runs.GetAsync(TestScope.WorkspaceId, pending.Value.Id, default))!.Value; + var childId = waiting.Steps.Single(step => step.StepName == "deliver").ChildFlowRunId; + Assert.IsNotNull(childId); + var child = (await runs.GetAsync(TestScope.WorkspaceId, childId, default))!.Value; + Assert.AreEqual("analyzed item", child.Input.GetProperty("article").GetString()); + Assert.AreEqual(0.9, child.Input.GetProperty("confidence").GetDouble()); + } + [TestMethod] public async Task CancellingAWaitingParentPropagatesToItsActiveChild() { @@ -213,14 +235,15 @@ private static FlowRunService Service( FlowFixture fixture, TestFlowRunQueue queue, FlowRunExecutionOptions? options = null, - IFlowOrchestrationEngine? orchestration = null) + IFlowOrchestrationEngine? orchestration = null, + IFlowAgentExecutor? agents = null) { var expressions = new FlowExpressionParser(); return new FlowRunService( fixture.Repository, queue, new TestCancellationRegistry(), - new TestAgentExecutor(), + agents ?? new TestAgentExecutor(), orchestration ?? new UnsupportedFlowOrchestrationEngine(), expressions, expressions, @@ -327,4 +350,43 @@ private static FlowGraphDefinition CallingGraph(string childName) new("analyze-cancelled", "analyze", "cancelled", "failure") ] }; + + private static FlowGraphDefinition AgentThenFlowGraph() => new() + { + EntryStep = "input", + Steps = + [ + new InputFlowStepDefinition { Name = "input" }, + new AgentFlowStepDefinition { Name = "analyze", Agent = new("news-agent") }, + new FlowCallStepDefinition + { + Name = "deliver", + Flow = new("child", FlowCallVersionStrategy.Exact, "1.0.0"), + InputMapping = JsonSerializer.SerializeToElement("${transition.output}") + } + ], + Transitions = + [ + new("input-analyze", "input", "completed", "analyze"), + new("analyze-deliver", "analyze", "completed", "deliver") + ] + }; + + private sealed class StructuredAgentExecutor : IFlowAgentExecutor + { + public Task ExecuteAsync( + FlowTargetReference target, + JsonElement input, + string correlationId, + CancellationToken cancellationToken) => + Task.FromResult(new FlowAgentExecutionResult( + JsonSerializer.SerializeToElement(new { article = "analyzed item", confidence = 0.9 }), + $"/agents/{target.Id}", + 3, + "/profiles/default", + "Deterministic", + null, + [], + [])); + } } diff --git a/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs b/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs index 61b61c02..baf2199f 100644 --- a/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs +++ b/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs @@ -106,14 +106,14 @@ public void EditablePaletteUsesOneGenericFlowCardAndShowsTheSelectedContract() StringAssert.Contains(rendered.Markup, "summary"); }); - rendered.Find("[data-testid='flow-pass-complete-input']").Change(true); + rendered.Find("[data-testid='flow-input-source']").Change("input"); var call = Assert.IsInstanceOfType(context.Services.GetRequiredService() .State.Resource!.Definition.Steps.Single(step => step.Type() == "flow")); Assert.AreEqual(JsonValueKind.String, call.InputMapping?.ValueKind); Assert.AreEqual("${input}", call.InputMapping?.GetString()); - StringAssert.Contains(rendered.Markup, "Pass the complete input"); + StringAssert.Contains(rendered.Markup, "Initial Flow input"); - rendered.Find("[data-testid='flow-pass-complete-input']").Change(false); + rendered.Find("[data-testid='flow-input-source']").Change(string.Empty); call = Assert.IsInstanceOfType(context.Services.GetRequiredService() .State.Resource!.Definition.Steps.Single(step => step.Type() == "flow")); Assert.AreEqual(JsonValueKind.Object, call.InputMapping?.ValueKind); @@ -161,6 +161,7 @@ public async Task ExistingFlowCardLoadsItsContractWhenSelectedAndPersistsPassthr Steps = [ new InputFlowStepDefinition { Name = "input" }, + new AgentFlowStepDefinition { Name = "analyze", DisplayName = "Analyze news", Agent = new("news-agent") }, new FlowCallStepDefinition { Name = "deliver", @@ -168,7 +169,11 @@ public async Task ExistingFlowCardLoadsItsContractWhenSelectedAndPersistsPassthr InputMapping = JsonSerializer.SerializeToElement(new { }) } ], - Transitions = [new("input-deliver", "input", "completed", "deliver")] + Transitions = + [ + new("input-analyze", "input", "completed", "analyze"), + new("analyze-deliver", "analyze", "completed", "deliver") + ] }; context.Services.AddSingleton(new BackendStub(readOnly: false, definition)); context.Services.AddSingleton(new ResourceProviderStub()); @@ -183,11 +188,11 @@ public async Task ExistingFlowCardLoadsItsContractWhenSelectedAndPersistsPassthr var canvas = rendered.FindComponent(); await rendered.InvokeAsync(() => canvas.Instance.SelectedStepChanged.InvokeAsync("deliver")); - rendered.WaitForAssertion(() => Assert.HasCount(1, rendered.FindAll("[data-testid='flow-pass-complete-input']"))); - rendered.Find("[data-testid='flow-pass-complete-input']").Change(true); + rendered.WaitForAssertion(() => Assert.HasCount(1, rendered.FindAll("[data-testid='flow-input-source']"))); + rendered.Find("[data-testid='flow-input-source']").Change("transition"); var call = Assert.IsInstanceOfType(context.Services.GetRequiredService() .State.Resource!.Definition.Steps.Single(step => step.Name == "deliver")); - Assert.AreEqual("${input}", call.InputMapping?.GetString()); + Assert.AreEqual("${transition.output}", call.InputMapping?.GetString()); } private sealed class ResourceProviderStub : IFlowDesignerResourceProvider From a363ecb6407300622c22d693322acd478150aa31 Mon Sep 17 00:00:00 2001 From: gbaudrit Date: Fri, 11 Sep 2026 01:32:02 +0200 Subject: [PATCH 4/4] fix(flow): simplify transition passthrough authoring --- docs/architecture.md | 2 +- .../Components/FlowDesigner.razor | 35 ++++++------------- .../Components/FlowDesignerStrings.fr-FR.resx | 2 +- .../Components/FlowDesignerStrings.resx | 2 +- .../FlowDesignerReadOnlyTests.cs | 12 +++---- 5 files changed, 20 insertions(+), 33 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index dde7108b..040e62ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -179,7 +179,7 @@ Entry / Trigger / REST / Console -> RootFlowSubmissionService WorkItem execution -> revalidates durable scope -> local graph execution or isolated MAF orchestration adapter ``` -The Flow module is physically independent and owns editable typed graph drafts, immutable published snapshots, constrained expressions, and the provider-neutral Flow Run model. The graph vocabulary includes one generic `Flow` call and one generic governed `Tool` call; resource catalogs populate their targets without adding provider-specific node types. Flow Application validates their logical references, mappings, and published schemas while remaining independent of Runtime and MCP implementations. A Flow call can map fields explicitly or use the Designer's complete-input source selector to pass either the initial structured input (`${input}`) or the output carried by the transition actually taken into the step (`${transition.output}`) unchanged. The latter remains correct when several branches converge on the same Flow call. The local executor traverses `Input`, `Agent`, `Flow`, `Tool`, `Router`, `Condition`, `Transform`, `Output`, and `Failure` steps sequentially. Infrastructure adapts agent steps, Management resource lookups, and Tool steps to the shared Tool Execution Pipeline. A Tool step records stable logical and per-attempt invocation identities, preserves workspace and principal scope, and projects Tool lifecycle and governance events into its owning Flow Run. +The Flow module is physically independent and owns editable typed graph drafts, immutable published snapshots, constrained expressions, and the provider-neutral Flow Run model. The graph vocabulary includes one generic `Flow` call and one generic governed `Tool` call; resource catalogs populate their targets without adding provider-specific node types. Flow Application validates their logical references, mappings, and published schemas while remaining independent of Runtime and MCP implementations. A Flow call can map fields explicitly or enable the Designer's passthrough checkbox to pass the output carried by the transition actually taken into the step (`${transition.output}`) unchanged. This remains correct when the initial Input step feeds the call and when several branches converge on the same Flow call. The local executor traverses `Input`, `Agent`, `Flow`, `Tool`, `Router`, `Condition`, `Transform`, `Output`, and `Failure` steps sequentially. Infrastructure adapts agent steps, Management resource lookups, and Tool steps to the shared Tool Execution Pipeline. A Tool step records stable logical and per-attempt invocation identities, preserves workspace and principal scope, and projects Tool lifecycle and governance events into its owning Flow Run. A Flow call creates a deterministic durable child Flow Run for the parent step attempt. The parent persists `WaitingForChild`, clears its execution lease, and releases the worker. The child captures the resolved immutable version, parent/root causality, nesting depth, and the parent's tenant, Workspace, Principal, interaction, and Work Task identities. A terminal child atomically moves a waiting parent back to `Pending`; replay reconstructs completed step outputs and resumes at the calling step. Startup recovery requeues lost parents or children, cancellation walks the active descendant tree, and configured depth and descendant limits bound composition. Both local SQLite and optional PostgreSQL persist these additions inside the existing Flow document payload, so this increment does not require a relational schema migration. diff --git a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor index 1b537354..071e2cc6 100644 --- a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor +++ b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor @@ -116,13 +116,9 @@ else @if (SelectedFlowVersion is not null) {
@T("InputMapping") - - @T("InputSourceHelp") - @if (!UsesCompleteInputSource(flowCall) + + @T("PassIncomingTransitionOutputHelp") + @if (!PassesIncomingTransitionOutput(flowCall.InputMapping) && SelectedFlowVersion.InputSchema is { ValueKind: JsonValueKind.Object } inputSchema && inputSchema.TryGetProperty("properties", out var inputProperties) && inputProperties.ValueKind == JsonValueKind.Object) @@ -410,16 +406,13 @@ else mapping[propertyName] = JsonSerializer.SerializeToElement(Text(e)); return UpdateSelectedAsync(call with { InputMapping = JsonSerializer.SerializeToElement(mapping) }); } - private Task UpdateFlowInputSourceAsync(ChangeEventArgs e) + private Task UpdateFlowTransitionPassthroughAsync(ChangeEventArgs e) { if (SelectedStep is not FlowCallStepDefinition call) return Task.CompletedTask; - var source = Text(e); - var mapping = source switch - { - "input" => JsonSerializer.SerializeToElement("${input}"), - "transition" => JsonSerializer.SerializeToElement("${transition.output}"), - _ => JsonSerializer.SerializeToElement(new { }) - }; + var enabled = e.Value is bool value && value; + var mapping = enabled + ? JsonSerializer.SerializeToElement("${transition.output}") + : JsonSerializer.SerializeToElement(new { }); return UpdateSelectedAsync(call with { InputMapping = mapping }); } private Task UpdateToolTargetAsync(ChangeEventArgs e) @@ -483,15 +476,9 @@ else private static string MappingValue(JsonElement? mapping, string propertyName) => mapping is { ValueKind: JsonValueKind.Object } value && value.TryGetProperty(propertyName, out var property) ? property.ValueKind == JsonValueKind.String ? property.GetString() ?? string.Empty : property.GetRawText() : string.Empty; - private static string CompleteInputSource(FlowCallStepDefinition call) - { - if (call.InputMapping is not { ValueKind: JsonValueKind.String } mapping) return string.Empty; - var expression = mapping.GetString(); - if (string.Equals(expression, "${input}", StringComparison.Ordinal)) return "input"; - if (string.Equals(expression, "${transition.output}", StringComparison.Ordinal)) return "transition"; - return string.Empty; - } - private static bool UsesCompleteInputSource(FlowCallStepDefinition call) => !string.IsNullOrEmpty(CompleteInputSource(call)); + private static bool PassesIncomingTransitionOutput(JsonElement? mapping) => + mapping is { ValueKind: JsonValueKind.String } value + && string.Equals(value.GetString(), "${transition.output}", StringComparison.Ordinal); private static string Text(ChangeEventArgs e) => e.Value?.ToString()?.Trim() ?? string.Empty; private static string? EmptyToNull(string value) => string.IsNullOrWhiteSpace(value) ? null : value; private static string NextVersion(string value) { var parts = value.Split('.'); return parts.Length == 3 && int.TryParse(parts[2], out var patch) ? $"{parts[0]}.{parts[1]}.{patch + 1}" : "1.0.0"; } diff --git a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx index a5fc6355..b7a5ee52 100644 --- a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx +++ b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.fr-FR.resx @@ -24,7 +24,7 @@ AgentSortie du routeur Flow appeléStratégie de version Version activeVersion exacte - Version exacteMappage d’entréeSource de l’entrée complèteMapper explicitement les propriétésEntrée initiale du FlowSortie de la transition entranteSélectionnez l’entrée initiale ou la sortie portée par la transition empruntée vers cette étape, ou mappez chaque propriété ci-dessous.Propriétés de sortie disponibles + Version exacteMappage d’entréeTransmettre la sortie de la transition entranteLe Flow enfant reçoit la sortie complète portée par la transition empruntée vers cette étape.Propriétés de sortie disponibles Tool appeléMappage des arguments Ce Tool est désactivé.Ce Tool est indisponible auprès de son fournisseur.Ce Tool nécessite une approbation avant son appel. Charger les agentsTout ajouter diff --git a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx index a8ea80a8..13f7abad 100644 --- a/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx +++ b/src/Agentstration.Web.FlowDesigner/Resources/Components/FlowDesignerStrings.resx @@ -30,7 +30,7 @@ AgentRouter output Called FlowVersion strategy Active versionExact version - Exact versionInput mappingComplete input sourceMap properties explicitlyInitial Flow inputIncoming transition outputSelect the initial input or the output carried by the transition taken into this step, or map each property below.Available output properties + Exact versionInput mappingPass incoming transition outputThe child Flow receives the complete output carried by the transition taken into this step.Available output properties Called ToolArguments mapping This Tool is disabled.This Tool is unavailable from its provider.This Tool requires approval before invocation. Load agentsAdd all diff --git a/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs b/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs index baf2199f..b734c362 100644 --- a/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs +++ b/tests/Agentstration.Web.FlowDesigner.Tests/FlowDesignerReadOnlyTests.cs @@ -106,14 +106,14 @@ public void EditablePaletteUsesOneGenericFlowCardAndShowsTheSelectedContract() StringAssert.Contains(rendered.Markup, "summary"); }); - rendered.Find("[data-testid='flow-input-source']").Change("input"); + rendered.Find("[data-testid='flow-pass-transition-output']").Change(true); var call = Assert.IsInstanceOfType(context.Services.GetRequiredService() .State.Resource!.Definition.Steps.Single(step => step.Type() == "flow")); Assert.AreEqual(JsonValueKind.String, call.InputMapping?.ValueKind); - Assert.AreEqual("${input}", call.InputMapping?.GetString()); - StringAssert.Contains(rendered.Markup, "Initial Flow input"); + Assert.AreEqual("${transition.output}", call.InputMapping?.GetString()); + StringAssert.Contains(rendered.Markup, "Pass incoming transition output"); - rendered.Find("[data-testid='flow-input-source']").Change(string.Empty); + rendered.Find("[data-testid='flow-pass-transition-output']").Change(false); call = Assert.IsInstanceOfType(context.Services.GetRequiredService() .State.Resource!.Definition.Steps.Single(step => step.Type() == "flow")); Assert.AreEqual(JsonValueKind.Object, call.InputMapping?.ValueKind); @@ -188,8 +188,8 @@ public async Task ExistingFlowCardLoadsItsContractWhenSelectedAndPersistsPassthr var canvas = rendered.FindComponent(); await rendered.InvokeAsync(() => canvas.Instance.SelectedStepChanged.InvokeAsync("deliver")); - rendered.WaitForAssertion(() => Assert.HasCount(1, rendered.FindAll("[data-testid='flow-input-source']"))); - rendered.Find("[data-testid='flow-input-source']").Change("transition"); + rendered.WaitForAssertion(() => Assert.HasCount(1, rendered.FindAll("[data-testid='flow-pass-transition-output']"))); + rendered.Find("[data-testid='flow-pass-transition-output']").Change(true); var call = Assert.IsInstanceOfType(context.Services.GetRequiredService() .State.Resource!.Definition.Steps.Single(step => step.Name == "deliver")); Assert.AreEqual("${transition.output}", call.InputMapping?.GetString());