diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs index 32ce93c2af..8bf99574ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs @@ -47,6 +47,9 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age InitializablePropertyPath variable = Throw.IfNull(this.Model.Variable); bool isValueUndefined = context.ReadState(variable.Path) is BlankValue; + // Snapshot prior-execution state before we mutate it below so the SkipQuestionMode + // evaluation reflects whether this is the first time the action has run. + bool hasExecutedPreviously = await this._hasExecuted.ReadAsync(context).ConfigureAwait(false); bool proceed = this.Evaluator.GetValue(this.Model.AlwaysPrompt).Value; if (!proceed) @@ -55,13 +58,18 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age proceed = mode switch { - SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => isValueUndefined && !await this._hasExecuted.ReadAsync(context).ConfigureAwait(false), + SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => isValueUndefined || hasExecutedPreviously, SkipQuestionMode.AlwaysSkipIfVariableHasValue => isValueUndefined, SkipQuestionMode.AlwaysAsk => true, _ => true, }; } + // Record that the action has executed in the same executor scope as the read above. + // (CaptureResponseAsync runs in a different executor's state scope, so writing it there + // would not be visible to subsequent ExecuteAsync invocations triggered by GotoAction.) + await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false); + if (proceed) { await this.PromptAsync(context, cancellationToken).ConfigureAwait(false); @@ -133,7 +141,6 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age } await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, extractedValue, context).ConfigureAwait(false); - await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false); await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs index ea260949fc..8703708e71 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs @@ -33,7 +33,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) => this.RunWorkflowAsync(GetWorkflowPath(workflowFileName, isSample: true), testcaseFileName, externalConveration); - [Theory(Skip = "Multi-turn tests hang in CI - needs investigation")] + [Theory] [InlineData("ConfirmInput.yaml", "ConfirmInput.json", false)] [InlineData("RequestExternalInput.yaml", "RequestExternalInput.json", false)] public Task ValidateMultiTurnAsync(string workflowFileName, string testcaseFileName, bool isSample) => diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConfirmInput.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConfirmInput.json index 4469633d23..22459727f1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConfirmInput.json +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/ConfirmInput.json @@ -1,11 +1,15 @@ { - "description": "Human in the loop sample - RequestExternalInput.yaml.", + "description": "Human in the loop sample - ConfirmInput.yaml. First response mismatches to exercise the GotoAction re-prompt path; second response matches and completes the workflow.", "setup": { "input": { "type": "String", "value": "1234" }, "responses": [ + { + "type": "String", + "value": "9999" + }, { "type": "String", "value": "1234" @@ -14,12 +18,18 @@ }, "validation": { "conversation_count": 1, - "min_action_count": 4, + "min_action_count": 6, "max_action_count": -1, "min_response_count": 0, "actions": { "start": [ - "set_project" + "set_project", + "question_confirm" + ], + "repeat": [ + "sendActivity_mismatch", + "goto_again", + "question_confirm" ], "final": [ "sendActivity_confirmed"