diff --git a/docs/architecture.md b/docs/architecture.md index 5ab00112..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. 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.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 2ed072c7..1ff2cc40 100644 --- a/src/Agentstration.Flow.Application/FlowValidationService.cs +++ b/src/Agentstration.Flow.Application/FlowValidationService.cs @@ -209,9 +209,19 @@ 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 + && 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, @@ -314,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); } @@ -353,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; } @@ -372,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 c2cd007a..071e2cc6 100644 --- a/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor +++ b/src/Agentstration.Web.FlowDesigner/Components/FlowDesigner.razor @@ -69,7 +69,7 @@ else
@@ -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("PassIncomingTransitionOutputHelp") + @if (!PassesIncomingTransitionOutput(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; + + } }
} @@ -315,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)); @@ -395,6 +406,15 @@ else mapping[propertyName] = JsonSerializer.SerializeToElement(Text(e)); return UpdateSelectedAsync(call with { InputMapping = JsonSerializer.SerializeToElement(mapping) }); } + private Task UpdateFlowTransitionPassthroughAsync(ChangeEventArgs e) + { + if (SelectedStep is not FlowCallStepDefinition call) return Task.CompletedTask; + 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) { if (SelectedStep is not ToolFlowStepDefinition tool || !TryParseToolKey(Text(e), out var target)) return Task.CompletedTask; @@ -456,6 +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 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 a081ef16..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éeProprié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 bd6aa0cf..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 mappingAvailable 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.Application.Tests/FlowCallAuthoringTests.cs b/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs index c4fa23d9..b2bf7b92 100644 --- a/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs +++ b/tests/Agentstration.Application.Tests/FlowCallAuthoringTests.cs @@ -67,6 +67,33 @@ public async Task FlowCallValidationUsesWorkspaceVersionSchemasAndRejectsIncompa Assert.AreEqual(ResourceNamespace.Default, resolver.OwnerNamespace); } + [TestMethod] + [DataRow("${input}")] + [DataRow("${transition.output}")] + public async Task FlowCallValidationAcceptsCompleteInputPassthrough(string inputMapping) + { + 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(inputMapping) + }); + + 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.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.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..b734c362 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-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("${transition.output}", call.InputMapping?.GetString()); + StringAssert.Contains(rendered.Markup, "Pass incoming transition output"); + + 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); + rendered.WaitForAssertion(() => StringAssert.Contains(rendered.Markup, "article")); } [TestMethod] @@ -137,6 +150,51 @@ 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 AgentFlowStepDefinition { Name = "analyze", DisplayName = "Analyze news", Agent = new("news-agent") }, + new FlowCallStepDefinition + { + Name = "deliver", + Flow = new("analysis", Namespace: new("pack.news")), + InputMapping = JsonSerializer.SerializeToElement(new { }) + } + ], + 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()); + 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-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()); + } + private sealed class ResourceProviderStub : IFlowDesignerResourceProvider { public Task> GetAgentsAsync(CancellationToken cancellationToken) => @@ -162,8 +220,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) @@ -179,10 +241,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\""); }